diff --git a/flicknote-cli/src/main_tests/mcp.rs b/flicknote-cli/src/main_tests/mcp.rs index bcaffa2..4bd19da 100644 --- a/flicknote-cli/src/main_tests/mcp.rs +++ b/flicknote-cli/src/main_tests/mcp.rs @@ -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(), @@ -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(), @@ -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() ); diff --git a/flicknote-cli/src/mcp/dto.rs b/flicknote-cli/src/mcp/dto.rs index 939a8d5..9bee3b2 100644 --- a/flicknote-cli/src/mcp/dto.rs +++ b/flicknote-cli/src/mcp/dto.rs @@ -21,6 +21,16 @@ pub(super) struct McpNoteSummary { pub deleted_at: Option, } +/// 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, +} + impl From for McpNoteSummary { fn from(note: NoteSummary) -> Self { Self { @@ -99,6 +109,12 @@ pub(super) struct McpProjectDto { pub created_at: Option, } +/// Object-wrapped project list; see `McpNoteListResult` for why. +#[derive(Debug, Serialize, JsonSchema)] +pub(super) struct McpProjectListResult { + pub projects: Vec, +} + impl From for McpProjectDto { fn from(project: ProjectDto) -> Self { Self { diff --git a/flicknote-cli/src/mcp/server.rs b/flicknote-cli/src/mcp/server.rs index 39149f6..67907d8 100644 --- a/flicknote-cli/src/mcp/server.rs +++ b/flicknote-cli/src/mcp/server.rs @@ -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::*; @@ -99,6 +100,22 @@ fn structured(result: Result) -> Result, 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 { + 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( @@ -109,7 +126,7 @@ impl FlickNoteMcp { async fn note_list( &self, Parameters(params): Parameters, - ) -> Result>, CallToolResult> { + ) -> Result, CallToolResult> { structured( self.call::>(AppRequest::NoteList(NoteListInput { note_type: params.note_type.map(|value| value.as_str().to_string()), @@ -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(), + }), ) } @@ -130,7 +149,7 @@ impl FlickNoteMcp { async fn note_find( &self, Parameters(params): Parameters, - ) -> Result>, CallToolResult> { + ) -> Result, CallToolResult> { structured( self.call::>(AppRequest::NoteFind(NoteFindInput { keywords: params.keywords, @@ -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(), + }), ) } @@ -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( @@ -460,13 +482,15 @@ impl FlickNoteMcp { async fn project_list( &self, Parameters(params): Parameters, - ) -> Result>, CallToolResult> { + ) -> Result, CallToolResult> { structured( self.call::>(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(), + }), ) }