Which MCP client do you use?
You can. I fixed the smart rules about 5 min after this image. You can do several of the things you mentioned, even ocr, 9. I didn’t try 5 at all
Please note that patching DEVONthink’s .plist files is highly discouraged, only do this at your own risk.
Wow! That is the thing I intend to create, but mostly with local AI in my just received MacBook Pro M5 Ultra 128 GB RAM. For now I have a folder with some shortcuts to Phython scripts, but my final idea is to have a real panel. Each button will open a specialised “what do you want” with common options pre-ready on button click or files drop.
I can’t attach the files here so going to paste them as code blocks. Apologies for length:
Source: dt_file_helper.py
#!/usr/bin/env python3
"""
JSON helper for file-record-to-dt.applescript.
The AppleScript handles all DEVONthink + OmniFocus calls. This helper
takes care of JSON parsing and output serialization, which is painful
to do natively in AppleScript (no JSON parser, escaping nightmares
inside `do shell script` strings).
Two subcommands:
read_field <queue.json> <key>
Prints the value of <key> from the JSON object in <queue.json>.
Booleans are emitted as the strings "true" or "false".
Missing keys print an empty string.
write_output <out.json> <success> <uuid> <action> <ofUpdated> <tocUpdated> [msg ...]
Writes a JSON object to <out.json> with the given fields.
Boolean fields accept the strings "true" / "false".
Trailing arguments are collected as the messages array.
Run only via osascript-launched do shell script. No interactive use.
"""
import json
import sys
def read_field() -> None:
queue_path = sys.argv[2]
key = sys.argv[3]
with open(queue_path, "r", encoding="utf-8") as f:
data = json.load(f)
value = data.get(key, "")
if value is None:
sys.stdout.write("")
elif isinstance(value, bool):
sys.stdout.write("true" if value else "false")
else:
sys.stdout.write(str(value))
def write_output() -> None:
out_path = sys.argv[2]
success = sys.argv[3] == "true"
uuid = sys.argv[4]
action = sys.argv[5]
of_updated = sys.argv[6] == "true"
toc_updated = sys.argv[7] == "true"
messages = list(sys.argv[8:])
payload = {
"success": success,
"uuid": uuid,
"action": action,
"ofUpdated": of_updated,
"tocUpdated": toc_updated,
"messages": messages,
}
with open(out_path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
f.write("\n")
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.stderr.write("usage: dt_file_helper.py {read_field|write_output} ...\n")
sys.exit(2)
cmd = sys.argv[1]
handlers = {"read_field": read_field, "write_output": write_output}
if cmd not in handlers:
sys.stderr.write(f"unknown subcommand: {cmd}\n")
sys.exit(2)
handlers[cmd]()
Source: file-record-to-dt.applescript
-- File Record to DEVONthink
-- ==========================
-- Generic helper that files a generated artifact (markdown text or imported file)
-- into a study's DEVONthink group, registers a labeled link in the corresponding
-- OmniFocus task note, and appends a markdown link to a Study Table of Contents
-- record inside DT.
--
-- Used by:
-- - any skill that produces a per-study artifact and needs to file it into
-- DT and register it back to OF
-- - examples: cached pre-meeting briefings (singleton mode); dated review
-- records where review history accumulates (non-singleton mode)
--
-- Operational model: queue file in, output file out (mirrors a separate
-- fetch_devonthink_records helper). The calling skill writes a JSON queue file
-- describing the operation, runs this script (via Drafts action, osascript, or
-- equivalent), then reads the JSON output to learn the resulting record UUID
-- and what changed.
--
-- INPUT: <workspace>/Support Files/dt-file-queue.json
-- OUTPUT: <workspace>/Support Files/dt-file-output.json
-- HELPER: <workspace>/Support Files/dt_file_helper.py
--
-- Configure the workspace path and OF project name in the properties block
-- below before running.
--
-- ============================================================
-- Queue JSON shape
-- ============================================================
-- {
-- "taskId": "abc123def45", // OF task id (PREFERRED - unambiguous)
-- "searchTerm": "STUDY-001", // OF task name fallback if taskId missing
-- "directoryUUID": "ABCDEF12-...", // DT group to file into
-- "tocUUID": "12345678-...", // Study TOC record
-- "recordName": "STUDY-001 - Briefing", // name in DT
-- "recordContentPath": "/abs/path/to/staging/briefing.md", // source file
-- "recordKind": "markdown", // "markdown" or "import"
-- "ofLabel": "Briefing", // OF note label prefix
-- "singleton": true, // replace existing if true
-- "tocDisplayName": "Briefing", // link text in TOC (defaults to recordName)
-- "tocSection": "Study Documents" // section header in TOC (without "##")
-- }
--
-- taskId vs searchTerm:
-- Always prefer taskId when calling from a skill: it is taken straight from
-- the task cache JSON (the "id" field on each task) and uniquely identifies
-- the OF task. searchTerm is a name-substring fallback for manual/ad-hoc
-- invocations only. Substring matching is fragile: tasks under the same
-- project often share substrings (e.g., a follow-up email task whose body
-- references the protocol number), and typos in the real task name will
-- prevent a match.
--
-- recordKind:
-- "markdown" - reads recordContentPath as UTF-8 text and creates a markdown
-- record in DEVONthink with that content.
-- "import" - imports the file at recordContentPath (PDF, DOCX, etc.) into
-- the directory group, then renames it to recordName.
--
-- singleton:
-- true - if a child record of the directory group already has the same
-- recordName, update its content in place (preserves UUID, OF link,
-- and TOC link). Use for evergreen artifacts that get regenerated
-- (e.g. cached protocol briefings).
-- false - always create a new record, append a new line to the OF note, and
-- append a new link to the TOC. Use for time-stamped artifacts where
-- history matters (e.g. dated PI Concerns / Budget Concerns reviews).
--
-- *** WARNING: non-singleton mode is NOT idempotent. ***
-- If the calling skill retries a non-singleton queue (because the
-- output file looks stale, the runner returned an ambiguous error,
-- etc.) and the underlying call actually succeeded, the retry will
-- create a duplicate record and a duplicate OF/TOC line with the
-- same name and date. The helper has no dedup check for non-
-- singleton calls because that's the whole point of the mode. When
-- in doubt, poll dt-file-output.json longer rather than retrying.
-- The output file is the source of truth for whether the call
-- landed; a transport-layer error from the runner does NOT mean the
-- AppleScript didn't run.
--
-- ============================================================
-- Output JSON shape
-- ============================================================
-- {
-- "success": true,
-- "uuid": "newly-created-or-existing-record-UUID",
-- "action": "created" | "updated" | "error",
-- "ofUpdated": true,
-- "tocUpdated": true,
-- "messages": ["any non-fatal warnings or notes"]
-- }
--
-- ============================================================
-- CONFIGURE THESE for your environment
property workspaceRelativePath : "path/to/your/workspace" -- relative to home folder
property ofProjectName : "Research Studies" -- name of the OF project that holds one task per study
on run
set homePath to POSIX path of (path to home folder)
set queuePath to homePath & workspaceRelativePath & "/Support Files/dt-file-queue.json"
set outputPath to homePath & workspaceRelativePath & "/Support Files/dt-file-output.json"
set helperPath to homePath & workspaceRelativePath & "/Support Files/dt_file_helper.py"
set msgs to {}
-- Sanity-check the queue file exists and is non-empty
set queueText to my readFileSafe(queuePath)
if queueText is "" then
my writeOutput(outputPath, helperPath, false, "", "error", false, false, {"queue file empty or missing: " & queuePath})
return "error: empty queue"
end if
-- Pull each field via the Python helper. Slower than parsing once, but
-- keeps the AppleScript readable and avoids inline JSON parsing.
set taskId to my jsonField(helperPath, queuePath, "taskId")
set searchTerm to my jsonField(helperPath, queuePath, "searchTerm")
set directoryUUID to my jsonField(helperPath, queuePath, "directoryUUID")
set tocUUID to my jsonField(helperPath, queuePath, "tocUUID")
set recordName to my jsonField(helperPath, queuePath, "recordName")
set recordContentPath to my jsonField(helperPath, queuePath, "recordContentPath")
set recordKind to my jsonField(helperPath, queuePath, "recordKind")
set ofLabel to my jsonField(helperPath, queuePath, "ofLabel")
set singletonStr to my jsonField(helperPath, queuePath, "singleton")
set tocDisplayName to my jsonField(helperPath, queuePath, "tocDisplayName")
set tocSection to my jsonField(helperPath, queuePath, "tocSection")
if tocSection is "" then set tocSection to "Study Documents"
if tocDisplayName is "" then set tocDisplayName to recordName
set isSingleton to (singletonStr is "true")
-- Validate required fields. Either taskId or searchTerm must be supplied
-- for the OF lookup; everything else is required for DT filing.
if directoryUUID is "" or recordName is "" or recordContentPath is "" or recordKind is "" or ofLabel is "" then
my writeOutput(outputPath, helperPath, false, "", "error", false, false, {"missing required field in queue (need directoryUUID, recordName, recordContentPath, recordKind, ofLabel)"})
return "error: missing field"
end if
if taskId is "" and searchTerm is "" then
my writeOutput(outputPath, helperPath, false, "", "error", false, false, {"queue must include either taskId (preferred) or searchTerm"})
return "error: missing OF identifier"
end if
-- ============================================================
-- STEP 1: File to DEVONthink (create or update)
-- ============================================================
set newUUID to ""
set actionType to ""
set existingFound to false
tell application id "DNtp"
set parentGroup to missing value
try
set parentGroup to get record with uuid directoryUUID
end try
if parentGroup is missing value then
my writeOutput(outputPath, helperPath, false, "", "error", false, false, {"directory group not found: " & directoryUUID})
return "error: directory group not found"
end if
set existingRecord to missing value
if isSingleton then
-- Look for an immediate child of parentGroup with the same name
set kids to children of parentGroup
repeat with i from 1 to count of kids
set k to item i of kids
if name of k is recordName then
set existingRecord to k
set existingFound to true
exit repeat
end if
end repeat
end if
if existingRecord is not missing value and recordKind is "markdown" then
-- Update markdown content in place; preserves UUID + existing OF link + TOC link
set newContent to my readFileSafe(recordContentPath)
set plain text of existingRecord to newContent
set newUUID to uuid of existingRecord
set actionType to "updated"
else
-- Create new (also covers the "import" singleton case where in-place
-- replacement isn't trivial; the old record stays in DT for the user
-- to clean up if needed).
if recordKind is "markdown" then
set newContent to my readFileSafe(recordContentPath)
try
set newRecord to create record with {name:recordName, type:markdown} in parentGroup
set plain text of newRecord to newContent
on error errMsg
my writeOutput(outputPath, helperPath, false, "", "error", false, false, {"failed to create markdown record: " & errMsg})
return "error: create failed"
end try
set newUUID to uuid of newRecord
set actionType to "created"
else if recordKind is "import" then
try
set newRecord to import recordContentPath to parentGroup
set name of newRecord to recordName
on error errMsg
my writeOutput(outputPath, helperPath, false, "", "error", false, false, {"failed to import file: " & errMsg})
return "error: import failed"
end try
set newUUID to uuid of newRecord
set actionType to "created"
if existingFound then
set end of msgs to "singleton import requested but cannot update PDFs/binaries in place; old record left for manual cleanup"
end if
else
my writeOutput(outputPath, helperPath, false, "", "error", false, false, {"unknown recordKind: " & recordKind & " (expected 'markdown' or 'import')"})
return "error: unknown recordKind"
end if
end if
end tell
if newUUID is "" then
my writeOutput(outputPath, helperPath, false, "", "error", false, false, {"failed to obtain new record UUID"})
return "error: no UUID"
end if
-- ============================================================
-- STEP 2: Update OmniFocus task note
-- ============================================================
set ofUpdated to false
set newOfLine to ofLabel & ": x-devonthink-item://" & newUUID
tell application "OmniFocus"
tell front document
set theProject to first flattened project where its name = ofProjectName
set theTask to missing value
-- Prefer taskId. Unambiguous, immune to typos and shared substrings.
if taskId is not "" then
try
set theTask to first flattened task of theProject whose id is taskId
end try
if theTask is missing value then
set end of msgs to "OF task not found for taskId: " & taskId
end if
end if
-- Fallback: name-based lookup. Try "starts with" first (anchored at
-- the start of the task name) so a follow-up email or sibling task
-- that merely mentions the protocol number doesn't get matched.
-- Only fall back to "contains" if "starts with" yields exactly one
-- candidate; never silently pick among multiple.
if theTask is missing value and searchTerm is not "" then
set startMatches to (every flattened task of theProject where its name starts with searchTerm)
if (count of startMatches) is 1 then
set theTask to item 1 of startMatches
else if (count of startMatches) > 1 then
set end of msgs to "multiple OF tasks start with searchTerm: " & searchTerm & " (count " & (count of startMatches) & "); pass taskId to disambiguate"
else
set containMatches to (every flattened task of theProject where its name contains searchTerm)
if (count of containMatches) is 1 then
set theTask to item 1 of containMatches
else if (count of containMatches) > 1 then
set end of msgs to "multiple OF tasks contain searchTerm: " & searchTerm & " (count " & (count of containMatches) & "); pass taskId to disambiguate"
else
set end of msgs to "OF task not found for searchTerm: " & searchTerm
end if
end if
end if
if theTask is missing value then
-- Skip OF update entirely; DT record was already created and TOC update can still proceed.
set existingNote to ""
else
set existingNote to note of theTask
if isSingleton then
-- For singletons we updated in place above, the existing OF
-- line already points at this UUID. Only append/replace if
-- it doesn't.
if existingNote contains newOfLine then
-- Already correct; nothing to do
else
-- Look for any line starting with "ofLabel: " and replace it.
set updatedNote to my replaceLineWithPrefix(existingNote, ofLabel & ": ", newOfLine)
if updatedNote is existingNote then
-- No existing line; append.
if existingNote is "" then
set updatedNote to newOfLine
else
set updatedNote to existingNote & return & return & newOfLine
end if
end if
set note of theTask to updatedNote
set ofUpdated to true
end if
else
-- Non-singleton: always append a new line (idempotent on exact match)
if existingNote does not contain newOfLine then
if existingNote is "" then
set note of theTask to newOfLine
else
set note of theTask to existingNote & return & return & newOfLine
end if
set ofUpdated to true
end if
end if
end if
end tell
end tell
-- ============================================================
-- STEP 3: Update Study TOC
-- ============================================================
set tocUpdated to false
set markdownLink to "- [" & tocDisplayName & "](x-devonthink-item://" & newUUID & ")"
set sectionHeader to "## " & tocSection
if tocUUID is not "" then
tell application id "DNtp"
set tocRecord to missing value
try
set tocRecord to get record with uuid tocUUID
end try
if tocRecord is missing value then
set end of msgs to "TOC record not found: " & tocUUID
else
set currentText to plain text of tocRecord
if currentText contains markdownLink then
-- Exact link already present (singleton update path); nothing to do
else
if isSingleton and (currentText contains ("[" & tocDisplayName & "]")) then
-- Display name already linked but with a different UUID; replace it
set newTocText to my replaceLineContaining(currentText, "[" & tocDisplayName & "]", markdownLink)
set plain text of tocRecord to newTocText
set tocUpdated to true
else
set newTocText to my appendUnderSection(currentText, sectionHeader, markdownLink)
set plain text of tocRecord to newTocText
set tocUpdated to true
end if
end if
end if
end tell
else
set end of msgs to "tocUUID not provided; skipped TOC update"
end if
-- ============================================================
-- Done
-- ============================================================
my writeOutput(outputPath, helperPath, true, newUUID, actionType, ofUpdated, tocUpdated, msgs)
return "ok: " & actionType & " " & newUUID
end run
-- ============================================================
-- Helpers
-- ============================================================
on readFileSafe(filePath)
try
set f to open for access POSIX file filePath
set txt to read f as «class utf8»
close access f
return txt
on error
try
close access POSIX file filePath
end try
return ""
end try
end readFileSafe
-- Pull a single string field out of a JSON file via the Python helper.
on jsonField(helperPath, queuePath, fieldName)
try
return do shell script "python3 " & quoted form of helperPath & " read_field " & quoted form of queuePath & " " & quoted form of fieldName
on error
return ""
end try
end jsonField
-- Append a markdown link at the end of a "## Section" block. If the section
-- doesn't exist, create it at the end of the document with a separator.
on appendUnderSection(currentText, sectionHeader, newLine)
if currentText does not contain sectionHeader then
return currentText & return & "---" & return & return & sectionHeader & return & return & newLine & return
end if
set pgs to paragraphs of currentText
set rebuilt to {}
set inSection to false
set inserted to false
repeat with i from 1 to count of pgs
set p to (item i of pgs) as text
if (not inserted) and inSection and (p starts with "## ") then
-- Hit the next section heading. Insert newLine before it (with a
-- blank line between newLine and the next heading).
set end of rebuilt to newLine
set end of rebuilt to ""
set end of rebuilt to p
set inserted to true
set inSection to false
else
set end of rebuilt to p
if p is sectionHeader then set inSection to true
end if
end repeat
if inSection and (not inserted) then
-- Section was the last one. Trim any trailing blank lines, then append.
repeat while (count of rebuilt) > 0 and ((item -1 of rebuilt) as text) is ""
if (count of rebuilt) is 1 then
set rebuilt to {}
else
set rebuilt to items 1 thru -2 of rebuilt
end if
end repeat
set end of rebuilt to newLine
end if
set AppleScript's text item delimiters to return
set out to rebuilt as text
set AppleScript's text item delimiters to ""
return out
end appendUnderSection
-- Replace the first line whose text starts with `prefix` with `replacement`.
-- Returns the original text unchanged if no line matches.
on replaceLineWithPrefix(theText, prefix, replacement)
set pgs to paragraphs of theText
set rebuilt to {}
set replaced to false
repeat with i from 1 to count of pgs
set p to (item i of pgs) as text
if (not replaced) and (p starts with prefix) then
set end of rebuilt to replacement
set replaced to true
else
set end of rebuilt to p
end if
end repeat
if not replaced then return theText
set AppleScript's text item delimiters to return
set out to rebuilt as text
set AppleScript's text item delimiters to ""
return out
end replaceLineWithPrefix
-- Replace the first line containing `needle` with `replacement`.
on replaceLineContaining(theText, needle, replacement)
set pgs to paragraphs of theText
set rebuilt to {}
set replaced to false
repeat with i from 1 to count of pgs
set p to (item i of pgs) as text
if (not replaced) and (p contains needle) then
set end of rebuilt to replacement
set replaced to true
else
set end of rebuilt to p
end if
end repeat
if not replaced then return theText
set AppleScript's text item delimiters to return
set out to rebuilt as text
set AppleScript's text item delimiters to ""
return out
end replaceLineContaining
-- Write the result JSON via the Python helper.
on writeOutput(outputPath, helperPath, success, theUUID, actionType, ofUpdated, tocUpdated, msgs)
set successStr to "false"
if success then set successStr to "true"
set ofStr to "false"
if ofUpdated then set ofStr to "true"
set tocStr to "false"
if tocUpdated then set tocStr to "true"
set cmd to "python3 " & quoted form of helperPath & " write_output " & quoted form of outputPath & " " & quoted form of successStr & " " & quoted form of theUUID & " " & quoted form of actionType & " " & quoted form of ofStr & " " & quoted form of tocStr
repeat with m in msgs
set cmd to cmd & " " & quoted form of (m as text)
end repeat
try
do shell script cmd
on error errMsg
-- Last-ditch fallback: write a minimal payload so the caller sees something
try
do shell script "echo '{\"success\":false,\"action\":\"error\",\"messages\":[\"output write failed\"]}' > " & quoted form of outputPath
end try
end try
end writeOutput
Thanks for sharing the scripts! At least DEVONthink’s part should be already doable via MCP but I don’t know if there’s an MCP server for OmniFocus.
when you say “already doable” are you saying there is an official MCP for DEVONthink available?
Thanks for detailed reply on your thoughts and experiences!
Nobody said anything… and if it was available, don’t you think you’d have heard about it ? ![]()
I referred to the 6 (!) open-source MCP servers for DEVONthink so far ![]()
gotcha. Waiting for official one to trust… particularly with HIPAA restrictions.
For me, the most crucial feature for the MCP (or better CLI-based skill) is retrieving search results within the documents, like the current GUI results. So AI agents can view the search results like humans.
I’m going to be honest… the wait and anticipation are killing me. ![]()
Technically I’m not using it as a MCP client; I’m using CODEX.
This is partially because Devonthink and the other tools I use aren’t enabled as MCP servers.
But it’s also because my theory is that I want to have a codebase that navigates my tools - without consuming tokens for general navigation. Then I just use an LLM when I explicitly want to. When the code uses an LLM it does it via the OpenAI APIs.
Yeah I say go for it - it’s been really satisfying. My original intension was to use a local LLM - so the initial use of OpenAI API was to prove the concept to myself. I’ve never got around to converting it to local LLM - but also congrats on the new RAM
which I don’t have, that’s my really constraint I guess.
This is an interesting case for me. Devonthink will give me a set of related documents and certainly surfacing things I forgot I had.
It some ways a pure LLM solution can’t so that without either coding a similar feature to what Devonthink already has, or consuming the content of all the Devonthink databases again (at a higher cost token-wise).
So if you want to not only see related document but also do an LLM pass to pick up key concepts, or concepts relating to a specific typic not specified in the documents themselves, the combination would work perfectly.
Your agent knows what you want (either because you told it directly or it looked at your research agenda and projects in another tool) and then rather than look at all documents, iy uses Devonthink to use a single document, or a group or search query, as the starting point, and then work through the related documents (that Devonthink surfaces) until it senses that it’s not finding anything new.
I have a suggestion for the DT4 web server which would nicely complement an MCP server.
Could you consider the ability for the DT4 web server to have a web viewer which displays a given document based upon X-Devonthink link? Or perhaps even opens the document so a specific page and/or a specific text search as specified by query parameters?
Retrieving data form Devonthink is fundamental to an MCP server. . This would make it much easier to view the retrieved data.
The prospect of an official Devonthink MCP is exciting – and I’m grateful to the team for considering making one.
For me, I am using AI tools more and more to supplement my work. I often use a harness such as Claude Code or OpenCode to run different LLMs and point the model to documents on my file system. I have been using this with great effect in an Obsidian vault folder - so that the LLM has context available and can create .md files. However, the downside is that my source documents (of which I have many) are stored mainly in Devonthink (directly or through an indexed folder). An MCP to allow the LLM to access information from Devonthink directly, and to also organise Devonthink files, would make this workflow even better. At the moment, I am accessing source files by giving the LLM a path to the document - but it’s a bit clunky to do that. It also doesn’t have access to my Devonthink tags or metadata, and so on.
to follow up on this topic (because I have been thinking about it a lot)…
In an ideal world, I would be able to:
- Query documents in my Devonthink database, and extract the content of files, so they can be read by a model in a Claude Code environment
- It would be incredible if the MCP could also allow item and page links to be accessible. For example, if my model in Claude Code finds a relevant passage of a PDF, to be able to quote the passage with a direct link to source. (This is one feature of Devonthink is use all day, every day - but mainly do this manually; it would be brilliant if a model could have access that to.)
- Access to smart groups and tags. That would be a huge help because I use these extensively within Devonthink to organise my files.
- Being able to apply tags and other metadata (eg a write not only read function) would be useful too.
- Database and group scoping.
Many thanks for considering!


