What would you do with an MCP server?

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 ? :wink:

2 Likes

I referred to the 6 (!) open-source MCP servers for DEVONthink so far :wink:

1 Like

gotcha. Waiting for official one to trust… particularly with HIPAA restrictions.

1 Like

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.

1 Like

I’m going to be honest… the wait and anticipation are killing me. :slightly_smiling_face:

1 Like

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 :slight_smile: which I don’t have, that’s my really constraint I guess.

1 Like

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.

1 Like

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!

2 Likes