Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,9 @@ only the keys listed above, and it treats the file as untrusted, because a
## Workspace root

`HACKMD_MCP_WORKSPACE_ROOT` confines every tool that takes a local path:
`hackmd_pull_note`, `hackmd_push_note`, `hackmd_get_note` with `local_path`, and
`hackmd_upload_note_image`. It must be absolute. A path outside the tree is
`hackmd_pull_note`, `hackmd_push_note`, `hackmd_get_note` with `local_path`,
`hackmd_create_note` with `local_path`, and `hackmd_upload_note_image`. It
must be absolute. A path outside the tree is
refused, whatever a note tells the agent, and the confinement holds even if a
symlink inside the tree is swapped while an operation runs. The root is opened
once at startup; on Unix, if it is moved or replaced afterwards, operations are
Expand All @@ -72,7 +73,8 @@ a boundary (an instruction file can import any Markdown file), so set a root if
an agent reads the tree you pull into.

`hackmd_upload_note_image` puts a local file behind a HackMD link, public
whenever the note is guest-readable, so it requires a root set in the server's
whenever the note is guest-readable, and `hackmd_create_note` with `local_path`
publishes a local file as a note, so both require a root set in the server's
own environment, not only in `.env`, and refuses every local upload otherwise. An
`image_url` upload reads no local file, so it needs no root. As
with every setting, a change takes effect only when the server restarts; in
Expand Down
18 changes: 18 additions & 0 deletions docs/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,24 @@ remains unconfirmed. The PATCH is never sent a second time. Image uploads are
the exception: nothing can look for an uploaded image, and a second upload
only leaves an unused copy, so an unreadable reply there is `upstream`.

## Publish a local file

`hackmd_create_note` with `local_path` (an absolute `.md` file) in place of
`content` reads the file on the server, so a large note never passes through
the conversation. `read_permission` is required, because HackMD's default for an
omitted one is unmeasured and may be more open than intended, and a file over
the large-file warning size needs `confirm_large_file: true`.

When the created note reads back with exactly that body, the file is tracked as
`hackmd_pull_note` would track it and `tracking.status` is `tracked`. Otherwise
it is `not_tracked` with a reason; the note still exists, so do not create it
again, and check the remote body before adopting it with `hackmd_pull_note`.

Like an image upload, this requires a workspace root set in the server's own
environment (see [configuration](configuration.md#workspace-root)). Before any
request, the server refuses a `local_path` whose file already has a sync record,
whose path is not `.md`, or that comes with `content`.

## Pull, edit locally, push

Sync turns a note into a Markdown file that any editor, script, or git
Expand Down
5 changes: 5 additions & 0 deletions src/fixture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,11 @@ pub(crate) fn unconfined_files(state_dir: PathBuf) -> LocalFiles {
LocalFiles::new(state_dir, None)
}

/// Local storage confined to `dir`, with its state under `dir/state`.
pub(crate) fn confined_files(dir: &std::path::Path) -> LocalFiles {
LocalFiles::new(dir.join("state"), Some(dir.into()))
}

/// A state directory for a test that never inspects it.
pub(crate) fn scratch_files() -> LocalFiles {
unconfined_files(std::env::temp_dir().join("hackmd-mcp-test"))
Expand Down
14 changes: 14 additions & 0 deletions src/note/crud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ pub(crate) struct CreateNoteInput {
pub(crate) title: Option<String>,
/// Full Markdown note body.
pub(crate) content: Option<String>,
/// Absolute path of a local `.md` file used as the body instead of
/// `content`; see `tracking` in the reply.
pub(crate) local_path: Option<std::path::PathBuf>,
/// Allows a `local_path` file past the large-file warning size.
#[serde(default)]
pub(crate) confirm_large_file: bool,
/// Complete replacement tag list.
pub(crate) tags: Option<Vec<String>>,
/// Optional note description.
Expand Down Expand Up @@ -244,6 +250,10 @@ pub(crate) enum CrudError {
"comment_permission and suggest_edit_permission cannot be set through the HackMD API (neither on create nor on PATCH); change them in the HackMD web UI"
)]
UnsupportedPermissionField,
#[error(
"local_path is read by the file-publishing path, not by create_note; this is a server bug, not a caller error"
)]
LocalPathNotHandled,
#[error("patch cannot be combined with other fields; send it in a call of its own")]
PatchWithFields,
#[error("nothing to update: give a patch, content, or the metadata fields to change")]
Expand Down Expand Up @@ -297,6 +307,7 @@ impl crate::reply::ToolError for CrudError {
| Self::TeamRestore => ErrorKind::InvalidInput,
Self::BodyChanged(_) => ErrorKind::Conflict,
Self::NoBodyToResend { .. } => ErrorKind::Upstream,
Self::LocalPathNotHandled => ErrorKind::Internal,
Self::Edit(error) => error.kind(),
Self::Payload(error) => error.kind(),
Self::Reference(error) => error.kind(),
Expand Down Expand Up @@ -428,6 +439,9 @@ pub(crate) async fn create_note(
if input.comment_permission.is_some() || input.suggest_edit_permission.is_some() {
return Err(CrudError::UnsupportedPermissionField);
}
if input.local_path.is_some() {
return Err(CrudError::LocalPathNotHandled);
}
let folder = input.parent_folder_id;
let mut payload = CreateNoteRequest {
title: input.title,
Expand Down
15 changes: 12 additions & 3 deletions src/server/note.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@

#[tool(
name = "hackmd_create_note",
description = "Create a HackMD note in a personal or team workspace and return its metadata and patch_path (not the body). Folder placement is read back after POST; a compatibility PATCH runs only if the API dropped parentFolderId, resending the body so it is kept. An explicit title always wins; when omitted, the body supplies a title from YAML title:, then the first H1, then Untitled. Pass read_permission whenever access matters. Sent title (unless blank), tags, description, permalink, and permissions the created note does not show are listed in unconfirmed_fields (no other field is checked); fix them with hackmd_update_note, never by creating the note again.",
description = "Create a HackMD note in a personal or team workspace and return its metadata and patch_path (not the body). Folder placement is read back after POST; a compatibility PATCH runs only if the API dropped parentFolderId, resending the body so it is kept. An explicit title always wins; when omitted, the body supplies a title from YAML title:, then the first H1, then Untitled. Pass read_permission whenever access matters. Sent title (unless blank), tags, description, permalink, and permissions the created note does not show are listed in unconfirmed_fields (no other field is checked); fix them with hackmd_update_note, never by creating the note again. local_path (an absolute .md file; needs read_permission and a workspace root set in the server's own environment) replaces content and tracks the file; see tracking.",
annotations(
title = "Create HackMD Note",
read_only_hint = false,
Expand All @@ -87,8 +87,17 @@
Parameters(input): Parameters<CreateNoteInput>,
) -> rmcp::model::CallToolResult {
reply::respond(
crate::note::crud::create_note(&self.client, input).await,
|output| format!("Created HackMD note {}", output.note.id),
crate::sync::adopt::create_note_from_file(&self.client, &self.files, input).await,
|output| {
let tracking = match &output.tracking {
Some(crate::sync::adopt::Tracking::Tracked { .. }) => "; local file tracked",

Check warning on line 93 in src/server/note.rs

View workflow job for this annotation

GitHub Actions / coverage

Changed source line was not covered on any platform
Some(crate::sync::adopt::Tracking::NotTracked { .. }) => {
"; local file NOT tracked"

Check warning on line 95 in src/server/note.rs

View workflow job for this annotation

GitHub Actions / coverage

Changed source line was not covered on any platform
}
None => "",
};
format!("Created HackMD note {}{tracking}", output.created.note.id)
},
)
}

Expand Down
1 change: 1 addition & 0 deletions src/sync.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Local-first sync: the tracked state store and the tools that move a note
//! between `HackMD` and a local Markdown file.

pub(crate) mod adopt;
pub(crate) mod check;
pub(crate) mod pull;
pub(crate) mod push;
Expand Down
Loading
Loading