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: 4 additions & 4 deletions flicknote-cli/src/main_tests/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ async fn mcp_note_queries_use_short_ids_and_hide_uuid() {
let listed = harness.call("note_list", serde_json::json!({})).await;
assert_eq!(listed["result"]["isError"], false);
assert_eq!(
listed["result"]["structuredContent"]
listed["result"]["structuredContent"]["notes"]
.as_array()
.unwrap()
.len(),
Expand Down Expand Up @@ -430,7 +430,7 @@ async fn mcp_note_mutations_and_lifecycle_route_through_daemon() {
)
.await;
assert_eq!(
found["result"]["structuredContent"]
found["result"]["structuredContent"]["notes"]
.as_array()
.unwrap()
.len(),
Expand Down Expand Up @@ -459,14 +459,14 @@ async fn mcp_project_and_source_contracts_are_preserved() {
let mut harness = McpHarness::start().await;
let projects = harness.call("project_list", serde_json::json!({})).await;
assert_eq!(
projects["result"]["structuredContent"]
projects["result"]["structuredContent"]["projects"]
.as_array()
.unwrap()
.len(),
1
);
assert!(
projects["result"]["structuredContent"][0]
projects["result"]["structuredContent"]["projects"][0]
.get("id")
.is_none()
);
Expand Down
16 changes: 16 additions & 0 deletions flicknote-cli/src/mcp/dto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,16 @@ pub(super) struct McpNoteSummary {
pub deleted_at: Option<String>,
}

/// Object-wrapped note list.
///
/// MCP 2025-era clients require `structuredContent` to be a JSON object
/// (record), rejecting bare arrays. Wrapping the list keeps the result
/// spec-compliant while preserving structured data.
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct McpNoteListResult {
pub notes: Vec<McpNoteSummary>,
}

impl From<NoteSummary> for McpNoteSummary {
fn from(note: NoteSummary) -> Self {
Self {
Expand Down Expand Up @@ -99,6 +109,12 @@ pub(super) struct McpProjectDto {
pub created_at: Option<String>,
}

/// Object-wrapped project list; see `McpNoteListResult` for why.
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct McpProjectListResult {
pub projects: Vec<McpProjectDto>,
}

impl From<ProjectDto> for McpProjectDto {
fn from(project: ProjectDto) -> Self {
Self {
Expand Down
40 changes: 32 additions & 8 deletions flicknote-cli/src/mcp/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ use flicknote_core::services::source::SourceResult;
use flicknote_sync::ipc::{AppRequest, AppResult, DaemonClient};
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::{CallToolResult, Implementation, ServerCapabilities, ServerInfo};
use rmcp::model::{CallToolResult, Implementation, JsonObject, ServerCapabilities, ServerInfo};
use rmcp::schemars::JsonSchema;
use rmcp::{Json, ServerHandler, ServiceExt, tool, tool_handler, tool_router};
use serde::Serialize;

use super::dto::{
McpNoteArchiveResult, McpNoteDetail, McpNoteMutationResult, McpNoteSummary, McpProjectDto,
McpNoteArchiveResult, McpNoteDetail, McpNoteListResult, McpNoteMutationResult, McpNoteSummary,
McpProjectDto, McpProjectListResult,
};
use super::error::tool_error;
use super::note_tools::*;
Expand Down Expand Up @@ -99,6 +100,22 @@ fn structured<T>(result: Result<T, ServiceError>) -> Result<Json<T>, CallToolRes
result.map(Json).map_err(|error| tool_error(&error))
}

/// Minimal, spec-compliant output schema (`type: "object"`).
///
/// MCP 2025-era clients validate `outputSchema.type` strictly and reject root
/// types other than `"object"` (e.g. the `"array"` schemas derived from list
/// return types, or the `oneOf` union derived from `SourceResult`). Each tool's
/// description documents the actual result shape, so this declaration stays
/// valid for strict clients without inventing structure.
fn object_output_schema() -> Arc<JsonObject> {
let mut schema = JsonObject::new();
schema.insert(
"type".to_string(),
serde_json::Value::String("object".to_string()),
);
Arc::new(schema)
}

#[tool_router(router = tool_router)]
impl FlickNoteMcp {
#[tool(
Expand All @@ -109,7 +126,7 @@ impl FlickNoteMcp {
async fn note_list(
&self,
Parameters(params): Parameters<NoteListParams>,
) -> Result<Json<Vec<McpNoteSummary>>, CallToolResult> {
) -> Result<Json<McpNoteListResult>, CallToolResult> {
structured(
self.call::<Vec<NoteSummary>>(AppRequest::NoteList(NoteListInput {
note_type: params.note_type.map(|value| value.as_str().to_string()),
Expand All @@ -118,7 +135,9 @@ impl FlickNoteMcp {
limit: params.limit,
}))
.await
.map(|notes| notes.into_iter().map(Into::into).collect()),
.map(|notes| McpNoteListResult {
notes: notes.into_iter().map(Into::into).collect(),
}),
)
}

Expand All @@ -130,7 +149,7 @@ impl FlickNoteMcp {
async fn note_find(
&self,
Parameters(params): Parameters<NoteFindParams>,
) -> Result<Json<Vec<McpNoteSummary>>, CallToolResult> {
) -> Result<Json<McpNoteListResult>, CallToolResult> {
structured(
self.call::<Vec<NoteSummary>>(AppRequest::NoteFind(NoteFindInput {
keywords: params.keywords,
Expand All @@ -140,7 +159,9 @@ impl FlickNoteMcp {
limit: params.limit,
}))
.await
.map(|notes| notes.into_iter().map(Into::into).collect()),
.map(|notes| McpNoteListResult {
notes: notes.into_iter().map(Into::into).collect(),
}),
)
}

Expand Down Expand Up @@ -205,6 +226,7 @@ impl FlickNoteMcp {
#[tool(
name = "note_source",
description = "Read stored source data as rendered content, raw JSON/text, or compact info. Normal notes often have no source data; use note_get for editable content. Use info then a 1-based range for large text or meeting sources.",
output_schema = object_output_schema(),
annotations(read_only_hint = true)
)]
async fn note_source(
Expand Down Expand Up @@ -460,13 +482,15 @@ impl FlickNoteMcp {
async fn project_list(
&self,
Parameters(params): Parameters<ProjectListParams>,
) -> Result<Json<Vec<McpProjectDto>>, CallToolResult> {
) -> Result<Json<McpProjectListResult>, CallToolResult> {
structured(
self.call::<Vec<ProjectDto>>(AppRequest::ProjectList {
include_archived: params.include_archived,
})
.await
.map(|projects| projects.into_iter().map(Into::into).collect()),
.map(|projects| McpProjectListResult {
projects: projects.into_iter().map(Into::into).collect(),
}),
)
}

Expand Down