diff --git a/proto/api/v1/memo_service.proto b/proto/api/v1/memo_service.proto index 4cc16136..ce568327 100644 --- a/proto/api/v1/memo_service.proto +++ b/proto/api/v1/memo_service.proto @@ -15,7 +15,9 @@ import "google/protobuf/timestamp.proto"; option go_package = "gen/api/v1"; service MemoService { - // CreateMemo creates a memo. + // CreateMemo creates a memo. The request body is a Memo; set its content + // (Markdown) and visibility (PRIVATE | PROTECTED | PUBLIC, default PRIVATE). + // The memo is owned by the authenticated user; requires authentication. rpc CreateMemo(CreateMemoRequest) returns (Memo) { option (google.api.http) = { post: "/api/v1/memos" @@ -46,7 +48,9 @@ service MemoService { option (google.api.http) = {delete: "/api/v1/{name=memos/*}"}; option (google.api.method_signature) = "name"; } - // SetMemoAttachments sets attachments for a memo. + // SetMemoAttachments replaces the full set of attachments on a memo with the + // provided list (not an append). Pass the complete desired set; an empty list + // clears all attachments. Idempotent. rpc SetMemoAttachments(SetMemoAttachmentsRequest) returns (google.protobuf.Empty) { option (google.api.http) = { patch: "/api/v1/{name=memos/*}/attachments" @@ -59,7 +63,9 @@ service MemoService { option (google.api.http) = {get: "/api/v1/{name=memos/*}/attachments"}; option (google.api.method_signature) = "name"; } - // SetMemoRelations sets relations for a memo. + // SetMemoRelations replaces the full set of relations on a memo with the + // provided list (not an append). Pass the complete desired set; an empty list + // clears all relations. Idempotent. rpc SetMemoRelations(SetMemoRelationsRequest) returns (google.protobuf.Empty) { option (google.api.http) = { patch: "/api/v1/{name=memos/*}/relations" @@ -90,7 +96,8 @@ service MemoService { option (google.api.http) = {get: "/api/v1/{name=memos/*}/reactions"}; option (google.api.method_signature) = "name"; } - // UpsertMemoReaction upserts a reaction for a memo. + // UpsertMemoReaction adds or updates the authenticated user's reaction on a + // memo. The reaction's content_id is the memo's resource name (memos/{memo}). rpc UpsertMemoReaction(UpsertMemoReactionRequest) returns (Reaction) { option (google.api.http) = { post: "/api/v1/{name=memos/*}/reactions" @@ -139,10 +146,14 @@ service MemoService { } } +// Visibility controls who can read a memo. enum Visibility { VISIBILITY_UNSPECIFIED = 0; + // PRIVATE: only the creator can read the memo. PRIVATE = 1; + // PROTECTED: signed-in users of the instance can read the memo. PROTECTED = 2; + // PUBLIC: anyone, including anonymous visitors, can read the memo. PUBLIC = 3; } @@ -222,6 +233,8 @@ message Memo { string content = 7 [(google.api.field_behavior) = REQUIRED]; // The visibility of the memo. + // One of PRIVATE (creator only), PROTECTED (signed-in users), or + // PUBLIC (anyone). Defaults to PRIVATE on creation when unspecified. Visibility visibility = 9 [(google.api.field_behavior) = REQUIRED]; // Output only. The tags extracted from the content. @@ -305,12 +318,24 @@ message ListMemosRequest { // Default to "create_time desc". // Supports comma-separated list of fields following AIP-132. // Example: "pinned desc, create_time desc" or "update_time asc" - // Supported fields: pinned, create_time, update_time, name + // Supported fields: pinned, create_time, update_time, name. + // Note: order_by uses create_time / update_time, while the filter + // expression uses created_ts / updated_ts for the same timestamps. string order_by = 4 [(google.api.field_behavior) = OPTIONAL]; - // Optional. Filter to apply to the list results. - // Filter is a CEL expression to filter memos. - // Refer to `Shortcut.filter`. + // Optional. A CEL expression to filter memos. Combine terms with && and ||. + // Available fields: + // content (string), creator (string, e.g. "users/1"), + // created_ts / updated_ts (timestamp), pinned (bool), + // visibility (string: PRIVATE | PROTECTED | PUBLIC), + // tags (list; match with `"work" in tags`, not `tag == "work"`), + // has_task_list / has_link / has_code / has_incomplete_tasks (bool). + // Note: the time fields here are created_ts / updated_ts, which differ from + // the create_time / update_time names used by order_by. + // Examples: + // pinned == true && visibility == "PUBLIC" + // tags.exists(t, t == "urgent") + // content.contains("roadmap") && created_ts > now - duration("168h") string filter = 5 [(google.api.field_behavior) = OPTIONAL]; // Optional. If true, show deleted memos in the response. diff --git a/proto/api/v1/shortcut_service.proto b/proto/api/v1/shortcut_service.proto index 2e21845b..ece02f9a 100644 --- a/proto/api/v1/shortcut_service.proto +++ b/proto/api/v1/shortcut_service.proto @@ -12,7 +12,9 @@ import "google/protobuf/field_mask.proto"; option go_package = "gen/api/v1"; service ShortcutService { - // ListShortcuts returns a list of shortcuts for a user. + // ListShortcuts returns a user's saved shortcuts. Each shortcut is a named, + // reusable CEL filter (see Shortcut.filter); pass its filter string directly + // to the ListMemos `filter` argument to reuse a saved view. rpc ListShortcuts(ListShortcutsRequest) returns (ListShortcutsResponse) { option (google.api.http) = {get: "/api/v1/{parent=users/*}/shortcuts"}; option (google.api.method_signature) = "parent"; @@ -64,7 +66,8 @@ message Shortcut { // The title of the shortcut. string title = 2 [(google.api.field_behavior) = REQUIRED]; - // The filter expression for the shortcut. + // The CEL filter expression for the shortcut, using the same grammar as the + // ListMemos `filter` argument. Reuse it by passing this value to ListMemos. string filter = 3 [(google.api.field_behavior) = OPTIONAL]; } diff --git a/proto/gen/api/v1/apiv1connect/memo_service.connect.go b/proto/gen/api/v1/apiv1connect/memo_service.connect.go index b37ea3f7..d7f29c57 100644 --- a/proto/gen/api/v1/apiv1connect/memo_service.connect.go +++ b/proto/gen/api/v1/apiv1connect/memo_service.connect.go @@ -93,7 +93,9 @@ const ( // MemoServiceClient is a client for the memos.api.v1.MemoService service. type MemoServiceClient interface { - // CreateMemo creates a memo. + // CreateMemo creates a memo. The request body is a Memo; set its content + // (Markdown) and visibility (PRIVATE | PROTECTED | PUBLIC, default PRIVATE). + // The memo is owned by the authenticated user; requires authentication. CreateMemo(context.Context, *connect.Request[v1.CreateMemoRequest]) (*connect.Response[v1.Memo], error) // ListMemos lists memos with pagination and filter. ListMemos(context.Context, *connect.Request[v1.ListMemosRequest]) (*connect.Response[v1.ListMemosResponse], error) @@ -103,11 +105,15 @@ type MemoServiceClient interface { UpdateMemo(context.Context, *connect.Request[v1.UpdateMemoRequest]) (*connect.Response[v1.Memo], error) // DeleteMemo deletes a memo. DeleteMemo(context.Context, *connect.Request[v1.DeleteMemoRequest]) (*connect.Response[emptypb.Empty], error) - // SetMemoAttachments sets attachments for a memo. + // SetMemoAttachments replaces the full set of attachments on a memo with the + // provided list (not an append). Pass the complete desired set; an empty list + // clears all attachments. Idempotent. SetMemoAttachments(context.Context, *connect.Request[v1.SetMemoAttachmentsRequest]) (*connect.Response[emptypb.Empty], error) // ListMemoAttachments lists attachments for a memo. ListMemoAttachments(context.Context, *connect.Request[v1.ListMemoAttachmentsRequest]) (*connect.Response[v1.ListMemoAttachmentsResponse], error) - // SetMemoRelations sets relations for a memo. + // SetMemoRelations replaces the full set of relations on a memo with the + // provided list (not an append). Pass the complete desired set; an empty list + // clears all relations. Idempotent. SetMemoRelations(context.Context, *connect.Request[v1.SetMemoRelationsRequest]) (*connect.Response[emptypb.Empty], error) // ListMemoRelations lists relations for a memo. ListMemoRelations(context.Context, *connect.Request[v1.ListMemoRelationsRequest]) (*connect.Response[v1.ListMemoRelationsResponse], error) @@ -117,7 +123,8 @@ type MemoServiceClient interface { ListMemoComments(context.Context, *connect.Request[v1.ListMemoCommentsRequest]) (*connect.Response[v1.ListMemoCommentsResponse], error) // ListMemoReactions lists reactions for a memo. ListMemoReactions(context.Context, *connect.Request[v1.ListMemoReactionsRequest]) (*connect.Response[v1.ListMemoReactionsResponse], error) - // UpsertMemoReaction upserts a reaction for a memo. + // UpsertMemoReaction adds or updates the authenticated user's reaction on a + // memo. The reaction's content_id is the memo's resource name (memos/{memo}). UpsertMemoReaction(context.Context, *connect.Request[v1.UpsertMemoReactionRequest]) (*connect.Response[v1.Reaction], error) // DeleteMemoReaction deletes a reaction for a memo. DeleteMemoReaction(context.Context, *connect.Request[v1.DeleteMemoReactionRequest]) (*connect.Response[emptypb.Empty], error) @@ -396,7 +403,9 @@ func (c *memoServiceClient) BatchGetLinkMetadata(ctx context.Context, req *conne // MemoServiceHandler is an implementation of the memos.api.v1.MemoService service. type MemoServiceHandler interface { - // CreateMemo creates a memo. + // CreateMemo creates a memo. The request body is a Memo; set its content + // (Markdown) and visibility (PRIVATE | PROTECTED | PUBLIC, default PRIVATE). + // The memo is owned by the authenticated user; requires authentication. CreateMemo(context.Context, *connect.Request[v1.CreateMemoRequest]) (*connect.Response[v1.Memo], error) // ListMemos lists memos with pagination and filter. ListMemos(context.Context, *connect.Request[v1.ListMemosRequest]) (*connect.Response[v1.ListMemosResponse], error) @@ -406,11 +415,15 @@ type MemoServiceHandler interface { UpdateMemo(context.Context, *connect.Request[v1.UpdateMemoRequest]) (*connect.Response[v1.Memo], error) // DeleteMemo deletes a memo. DeleteMemo(context.Context, *connect.Request[v1.DeleteMemoRequest]) (*connect.Response[emptypb.Empty], error) - // SetMemoAttachments sets attachments for a memo. + // SetMemoAttachments replaces the full set of attachments on a memo with the + // provided list (not an append). Pass the complete desired set; an empty list + // clears all attachments. Idempotent. SetMemoAttachments(context.Context, *connect.Request[v1.SetMemoAttachmentsRequest]) (*connect.Response[emptypb.Empty], error) // ListMemoAttachments lists attachments for a memo. ListMemoAttachments(context.Context, *connect.Request[v1.ListMemoAttachmentsRequest]) (*connect.Response[v1.ListMemoAttachmentsResponse], error) - // SetMemoRelations sets relations for a memo. + // SetMemoRelations replaces the full set of relations on a memo with the + // provided list (not an append). Pass the complete desired set; an empty list + // clears all relations. Idempotent. SetMemoRelations(context.Context, *connect.Request[v1.SetMemoRelationsRequest]) (*connect.Response[emptypb.Empty], error) // ListMemoRelations lists relations for a memo. ListMemoRelations(context.Context, *connect.Request[v1.ListMemoRelationsRequest]) (*connect.Response[v1.ListMemoRelationsResponse], error) @@ -420,7 +433,8 @@ type MemoServiceHandler interface { ListMemoComments(context.Context, *connect.Request[v1.ListMemoCommentsRequest]) (*connect.Response[v1.ListMemoCommentsResponse], error) // ListMemoReactions lists reactions for a memo. ListMemoReactions(context.Context, *connect.Request[v1.ListMemoReactionsRequest]) (*connect.Response[v1.ListMemoReactionsResponse], error) - // UpsertMemoReaction upserts a reaction for a memo. + // UpsertMemoReaction adds or updates the authenticated user's reaction on a + // memo. The reaction's content_id is the memo's resource name (memos/{memo}). UpsertMemoReaction(context.Context, *connect.Request[v1.UpsertMemoReactionRequest]) (*connect.Response[v1.Reaction], error) // DeleteMemoReaction deletes a reaction for a memo. DeleteMemoReaction(context.Context, *connect.Request[v1.DeleteMemoReactionRequest]) (*connect.Response[emptypb.Empty], error) diff --git a/proto/gen/api/v1/apiv1connect/shortcut_service.connect.go b/proto/gen/api/v1/apiv1connect/shortcut_service.connect.go index 7d97c3fe..f93c4c10 100644 --- a/proto/gen/api/v1/apiv1connect/shortcut_service.connect.go +++ b/proto/gen/api/v1/apiv1connect/shortcut_service.connect.go @@ -53,7 +53,9 @@ const ( // ShortcutServiceClient is a client for the memos.api.v1.ShortcutService service. type ShortcutServiceClient interface { - // ListShortcuts returns a list of shortcuts for a user. + // ListShortcuts returns a user's saved shortcuts. Each shortcut is a named, + // reusable CEL filter (see Shortcut.filter); pass its filter string directly + // to the ListMemos `filter` argument to reuse a saved view. ListShortcuts(context.Context, *connect.Request[v1.ListShortcutsRequest]) (*connect.Response[v1.ListShortcutsResponse], error) // GetShortcut gets a shortcut by name. GetShortcut(context.Context, *connect.Request[v1.GetShortcutRequest]) (*connect.Response[v1.Shortcut], error) @@ -145,7 +147,9 @@ func (c *shortcutServiceClient) DeleteShortcut(ctx context.Context, req *connect // ShortcutServiceHandler is an implementation of the memos.api.v1.ShortcutService service. type ShortcutServiceHandler interface { - // ListShortcuts returns a list of shortcuts for a user. + // ListShortcuts returns a user's saved shortcuts. Each shortcut is a named, + // reusable CEL filter (see Shortcut.filter); pass its filter string directly + // to the ListMemos `filter` argument to reuse a saved view. ListShortcuts(context.Context, *connect.Request[v1.ListShortcutsRequest]) (*connect.Response[v1.ListShortcutsResponse], error) // GetShortcut gets a shortcut by name. GetShortcut(context.Context, *connect.Request[v1.GetShortcutRequest]) (*connect.Response[v1.Shortcut], error) diff --git a/proto/gen/api/v1/memo_service.pb.go b/proto/gen/api/v1/memo_service.pb.go index 3fac50ed..7224f429 100644 --- a/proto/gen/api/v1/memo_service.pb.go +++ b/proto/gen/api/v1/memo_service.pb.go @@ -25,13 +25,17 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// Visibility controls who can read a memo. type Visibility int32 const ( Visibility_VISIBILITY_UNSPECIFIED Visibility = 0 - Visibility_PRIVATE Visibility = 1 - Visibility_PROTECTED Visibility = 2 - Visibility_PUBLIC Visibility = 3 + // PRIVATE: only the creator can read the memo. + Visibility_PRIVATE Visibility = 1 + // PROTECTED: signed-in users of the instance can read the memo. + Visibility_PROTECTED Visibility = 2 + // PUBLIC: anyone, including anonymous visitors, can read the memo. + Visibility_PUBLIC Visibility = 3 ) // Enum value maps for Visibility. @@ -231,6 +235,8 @@ type Memo struct { // Required. The content of the memo in Markdown format. Content string `protobuf:"bytes,7,opt,name=content,proto3" json:"content,omitempty"` // The visibility of the memo. + // One of PRIVATE (creator only), PROTECTED (signed-in users), or + // PUBLIC (anyone). Defaults to PRIVATE on creation when unspecified. Visibility Visibility `protobuf:"varint,9,opt,name=visibility,proto3,enum=memos.api.v1.Visibility" json:"visibility,omitempty"` // Output only. The tags extracted from the content. Tags []string `protobuf:"bytes,10,rep,name=tags,proto3" json:"tags,omitempty"` @@ -532,11 +538,26 @@ type ListMemosRequest struct { // Default to "create_time desc". // Supports comma-separated list of fields following AIP-132. // Example: "pinned desc, create_time desc" or "update_time asc" - // Supported fields: pinned, create_time, update_time, name + // Supported fields: pinned, create_time, update_time, name. + // Note: order_by uses create_time / update_time, while the filter + // expression uses created_ts / updated_ts for the same timestamps. OrderBy string `protobuf:"bytes,4,opt,name=order_by,json=orderBy,proto3" json:"order_by,omitempty"` - // Optional. Filter to apply to the list results. - // Filter is a CEL expression to filter memos. - // Refer to `Shortcut.filter`. + // Optional. A CEL expression to filter memos. Combine terms with && and ||. + // Available fields: + // + // content (string), creator (string, e.g. "users/1"), + // created_ts / updated_ts (timestamp), pinned (bool), + // visibility (string: PRIVATE | PROTECTED | PUBLIC), + // tags (list; match with `"work" in tags`, not `tag == "work"`), + // has_task_list / has_link / has_code / has_incomplete_tasks (bool). + // + // Note: the time fields here are created_ts / updated_ts, which differ from + // the create_time / update_time names used by order_by. + // Examples: + // + // pinned == true && visibility == "PUBLIC" + // tags.exists(t, t == "urgent") + // content.contains("roadmap") && created_ts > now - duration("168h") Filter string `protobuf:"bytes,5,opt,name=filter,proto3" json:"filter,omitempty"` // Optional. If true, show deleted memos in the response. ShowDeleted bool `protobuf:"varint,6,opt,name=show_deleted,json=showDeleted,proto3" json:"show_deleted,omitempty"` diff --git a/proto/gen/api/v1/memo_service_grpc.pb.go b/proto/gen/api/v1/memo_service_grpc.pb.go index 7bfc47ce..e370acc9 100644 --- a/proto/gen/api/v1/memo_service_grpc.pb.go +++ b/proto/gen/api/v1/memo_service_grpc.pb.go @@ -46,7 +46,9 @@ const ( // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type MemoServiceClient interface { - // CreateMemo creates a memo. + // CreateMemo creates a memo. The request body is a Memo; set its content + // (Markdown) and visibility (PRIVATE | PROTECTED | PUBLIC, default PRIVATE). + // The memo is owned by the authenticated user; requires authentication. CreateMemo(ctx context.Context, in *CreateMemoRequest, opts ...grpc.CallOption) (*Memo, error) // ListMemos lists memos with pagination and filter. ListMemos(ctx context.Context, in *ListMemosRequest, opts ...grpc.CallOption) (*ListMemosResponse, error) @@ -56,11 +58,15 @@ type MemoServiceClient interface { UpdateMemo(ctx context.Context, in *UpdateMemoRequest, opts ...grpc.CallOption) (*Memo, error) // DeleteMemo deletes a memo. DeleteMemo(ctx context.Context, in *DeleteMemoRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) - // SetMemoAttachments sets attachments for a memo. + // SetMemoAttachments replaces the full set of attachments on a memo with the + // provided list (not an append). Pass the complete desired set; an empty list + // clears all attachments. Idempotent. SetMemoAttachments(ctx context.Context, in *SetMemoAttachmentsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) // ListMemoAttachments lists attachments for a memo. ListMemoAttachments(ctx context.Context, in *ListMemoAttachmentsRequest, opts ...grpc.CallOption) (*ListMemoAttachmentsResponse, error) - // SetMemoRelations sets relations for a memo. + // SetMemoRelations replaces the full set of relations on a memo with the + // provided list (not an append). Pass the complete desired set; an empty list + // clears all relations. Idempotent. SetMemoRelations(ctx context.Context, in *SetMemoRelationsRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) // ListMemoRelations lists relations for a memo. ListMemoRelations(ctx context.Context, in *ListMemoRelationsRequest, opts ...grpc.CallOption) (*ListMemoRelationsResponse, error) @@ -70,7 +76,8 @@ type MemoServiceClient interface { ListMemoComments(ctx context.Context, in *ListMemoCommentsRequest, opts ...grpc.CallOption) (*ListMemoCommentsResponse, error) // ListMemoReactions lists reactions for a memo. ListMemoReactions(ctx context.Context, in *ListMemoReactionsRequest, opts ...grpc.CallOption) (*ListMemoReactionsResponse, error) - // UpsertMemoReaction upserts a reaction for a memo. + // UpsertMemoReaction adds or updates the authenticated user's reaction on a + // memo. The reaction's content_id is the memo's resource name (memos/{memo}). UpsertMemoReaction(ctx context.Context, in *UpsertMemoReactionRequest, opts ...grpc.CallOption) (*Reaction, error) // DeleteMemoReaction deletes a reaction for a memo. DeleteMemoReaction(ctx context.Context, in *DeleteMemoReactionRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) @@ -301,7 +308,9 @@ func (c *memoServiceClient) BatchGetLinkMetadata(ctx context.Context, in *BatchG // All implementations must embed UnimplementedMemoServiceServer // for forward compatibility. type MemoServiceServer interface { - // CreateMemo creates a memo. + // CreateMemo creates a memo. The request body is a Memo; set its content + // (Markdown) and visibility (PRIVATE | PROTECTED | PUBLIC, default PRIVATE). + // The memo is owned by the authenticated user; requires authentication. CreateMemo(context.Context, *CreateMemoRequest) (*Memo, error) // ListMemos lists memos with pagination and filter. ListMemos(context.Context, *ListMemosRequest) (*ListMemosResponse, error) @@ -311,11 +320,15 @@ type MemoServiceServer interface { UpdateMemo(context.Context, *UpdateMemoRequest) (*Memo, error) // DeleteMemo deletes a memo. DeleteMemo(context.Context, *DeleteMemoRequest) (*emptypb.Empty, error) - // SetMemoAttachments sets attachments for a memo. + // SetMemoAttachments replaces the full set of attachments on a memo with the + // provided list (not an append). Pass the complete desired set; an empty list + // clears all attachments. Idempotent. SetMemoAttachments(context.Context, *SetMemoAttachmentsRequest) (*emptypb.Empty, error) // ListMemoAttachments lists attachments for a memo. ListMemoAttachments(context.Context, *ListMemoAttachmentsRequest) (*ListMemoAttachmentsResponse, error) - // SetMemoRelations sets relations for a memo. + // SetMemoRelations replaces the full set of relations on a memo with the + // provided list (not an append). Pass the complete desired set; an empty list + // clears all relations. Idempotent. SetMemoRelations(context.Context, *SetMemoRelationsRequest) (*emptypb.Empty, error) // ListMemoRelations lists relations for a memo. ListMemoRelations(context.Context, *ListMemoRelationsRequest) (*ListMemoRelationsResponse, error) @@ -325,7 +338,8 @@ type MemoServiceServer interface { ListMemoComments(context.Context, *ListMemoCommentsRequest) (*ListMemoCommentsResponse, error) // ListMemoReactions lists reactions for a memo. ListMemoReactions(context.Context, *ListMemoReactionsRequest) (*ListMemoReactionsResponse, error) - // UpsertMemoReaction upserts a reaction for a memo. + // UpsertMemoReaction adds or updates the authenticated user's reaction on a + // memo. The reaction's content_id is the memo's resource name (memos/{memo}). UpsertMemoReaction(context.Context, *UpsertMemoReactionRequest) (*Reaction, error) // DeleteMemoReaction deletes a reaction for a memo. DeleteMemoReaction(context.Context, *DeleteMemoReactionRequest) (*emptypb.Empty, error) diff --git a/proto/gen/api/v1/shortcut_service.pb.go b/proto/gen/api/v1/shortcut_service.pb.go index 6550fdbd..70d1aa60 100644 --- a/proto/gen/api/v1/shortcut_service.pb.go +++ b/proto/gen/api/v1/shortcut_service.pb.go @@ -31,7 +31,8 @@ type Shortcut struct { Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // The title of the shortcut. Title string `protobuf:"bytes,2,opt,name=title,proto3" json:"title,omitempty"` - // The filter expression for the shortcut. + // The CEL filter expression for the shortcut, using the same grammar as the + // ListMemos `filter` argument. Reuse it by passing this value to ListMemos. Filter string `protobuf:"bytes,3,opt,name=filter,proto3" json:"filter,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache diff --git a/proto/gen/api/v1/shortcut_service_grpc.pb.go b/proto/gen/api/v1/shortcut_service_grpc.pb.go index 95bf901b..840c35be 100644 --- a/proto/gen/api/v1/shortcut_service_grpc.pb.go +++ b/proto/gen/api/v1/shortcut_service_grpc.pb.go @@ -31,7 +31,9 @@ const ( // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type ShortcutServiceClient interface { - // ListShortcuts returns a list of shortcuts for a user. + // ListShortcuts returns a user's saved shortcuts. Each shortcut is a named, + // reusable CEL filter (see Shortcut.filter); pass its filter string directly + // to the ListMemos `filter` argument to reuse a saved view. ListShortcuts(ctx context.Context, in *ListShortcutsRequest, opts ...grpc.CallOption) (*ListShortcutsResponse, error) // GetShortcut gets a shortcut by name. GetShortcut(ctx context.Context, in *GetShortcutRequest, opts ...grpc.CallOption) (*Shortcut, error) @@ -105,7 +107,9 @@ func (c *shortcutServiceClient) DeleteShortcut(ctx context.Context, in *DeleteSh // All implementations must embed UnimplementedShortcutServiceServer // for forward compatibility. type ShortcutServiceServer interface { - // ListShortcuts returns a list of shortcuts for a user. + // ListShortcuts returns a user's saved shortcuts. Each shortcut is a named, + // reusable CEL filter (see Shortcut.filter); pass its filter string directly + // to the ListMemos `filter` argument to reuse a saved view. ListShortcuts(context.Context, *ListShortcutsRequest) (*ListShortcutsResponse, error) // GetShortcut gets a shortcut by name. GetShortcut(context.Context, *GetShortcutRequest) (*Shortcut, error) diff --git a/proto/gen/openapi.yaml b/proto/gen/openapi.yaml index bdcbb2b0..7e9f90b8 100644 --- a/proto/gen/openapi.yaml +++ b/proto/gen/openapi.yaml @@ -648,15 +648,27 @@ paths: Default to "create_time desc". Supports comma-separated list of fields following AIP-132. Example: "pinned desc, create_time desc" or "update_time asc" - Supported fields: pinned, create_time, update_time, name + Supported fields: pinned, create_time, update_time, name. + Note: order_by uses create_time / update_time, while the filter + expression uses created_ts / updated_ts for the same timestamps. schema: type: string - name: filter in: query description: |- - Optional. Filter to apply to the list results. - Filter is a CEL expression to filter memos. - Refer to `Shortcut.filter`. + Optional. A CEL expression to filter memos. Combine terms with && and ||. + Available fields: + content (string), creator (string, e.g. "users/1"), + created_ts / updated_ts (timestamp), pinned (bool), + visibility (string: PRIVATE | PROTECTED | PUBLIC), + tags (list; match with `"work" in tags`, not `tag == "work"`), + has_task_list / has_link / has_code / has_incomplete_tasks (bool). + Note: the time fields here are created_ts / updated_ts, which differ from + the create_time / update_time names used by order_by. + Examples: + pinned == true && visibility == "PUBLIC" + tags.exists(t, t == "urgent") + content.contains("roadmap") && created_ts > now - duration("168h") schema: type: string - name: showDeleted @@ -680,7 +692,10 @@ paths: post: tags: - MemoService - description: CreateMemo creates a memo. + description: |- + CreateMemo creates a memo. The request body is a Memo; set its content + (Markdown) and visibility (PRIVATE | PROTECTED | PUBLIC, default PRIVATE). + The memo is owned by the authenticated user; requires authentication. operationId: MemoService_CreateMemo parameters: - name: memoId @@ -889,7 +904,10 @@ paths: patch: tags: - MemoService - description: SetMemoAttachments sets attachments for a memo. + description: |- + SetMemoAttachments replaces the full set of attachments on a memo with the + provided list (not an append). Pass the complete desired set; an empty list + clears all attachments. Idempotent. operationId: MemoService_SetMemoAttachments parameters: - name: memo @@ -1032,7 +1050,9 @@ paths: post: tags: - MemoService - description: UpsertMemoReaction upserts a reaction for a memo. + description: |- + UpsertMemoReaction adds or updates the authenticated user's reaction on a + memo. The reaction's content_id is the memo's resource name (memos/{memo}). operationId: MemoService_UpsertMemoReaction parameters: - name: memo @@ -1129,7 +1149,10 @@ paths: patch: tags: - MemoService - description: SetMemoRelations sets relations for a memo. + description: |- + SetMemoRelations replaces the full set of relations on a memo with the + provided list (not an append). Pass the complete desired set; an empty list + clears all relations. Idempotent. operationId: MemoService_SetMemoRelations parameters: - name: memo @@ -1919,7 +1942,10 @@ paths: get: tags: - ShortcutService - description: ListShortcuts returns a list of shortcuts for a user. + description: |- + ListShortcuts returns a user's saved shortcuts. Each shortcut is a named, + reusable CEL filter (see Shortcut.filter); pass its filter string directly + to the ListMemos `filter` argument to reuse a saved view. operationId: ShortcutService_ListShortcuts parameters: - name: user @@ -3262,7 +3288,10 @@ components: - PROTECTED - PUBLIC type: string - description: The visibility of the memo. + description: |- + The visibility of the memo. + One of PRIVATE (creator only), PROTECTED (signed-in users), or + PUBLIC (anyone). Defaults to PRIVATE on creation when unspecified. format: enum tags: readOnly: true @@ -3569,7 +3598,9 @@ components: description: The title of the shortcut. filter: type: string - description: The filter expression for the shortcut. + description: |- + The CEL filter expression for the shortcut, using the same grammar as the + ListMemos `filter` argument. Reuse it by passing this value to ListMemos. SignInRequest: type: object properties: diff --git a/server/router/mcp/README.md b/server/router/mcp/README.md index faf26349..130018f8 100644 --- a/server/router/mcp/README.md +++ b/server/router/mcp/README.md @@ -131,8 +131,11 @@ a personal access token as a bearer credential. Example client config: ## Tool surface -The first version exposes a curated allowlist (`curatedOperationIDs` in -`catalog.go`), all memo- and attachment-focused: +The server exposes a curated allowlist (`curatedOperationIDs` in `catalog.go`), +centered on memos and attachments, plus two read-only orientation tools: +`shortcut_list_shortcuts` (surfaces a user's saved CEL filters for reuse with +`memo_list_memos`) and `auth_get_current_user` (a "whoami" so an agent can +resolve its own user — the single allowed auth/identity operation): | OpenAPI operation | MCP tool | | --- | --- | @@ -153,12 +156,14 @@ The first version exposes a curated allowlist (`curatedOperationIDs` in | `AttachmentService_ListAttachments` | `attachment_list_attachments` | | `AttachmentService_GetAttachment` | `attachment_get_attachment` | | `AttachmentService_DeleteAttachment` | `attachment_delete_attachment` | +| `ShortcutService_ListShortcuts` | `shortcut_list_shortcuts` | +| `AuthService_GetCurrentUser` | `auth_get_current_user` | **Naming rule** (`toolNameFromOperationID`): drop the `Service` suffix from the subject and convert both subject and method from camelCase to snake_case, joined by `_`. So `MemoService_ListMemos → memo_list_memos`. -**Annotations** (`annotationsForMethod`) are derived from the HTTP method: +**Annotations** (`annotationsForOperation`) start from the HTTP method: | Method | ReadOnly | Destructive | Idempotent | | --- | --- | --- | --- | @@ -166,6 +171,11 @@ by `_`. So `MemoService_ListMemos → memo_list_memos`. | DELETE | false | true | true | | other (POST, PATCH, …) | false | false | false | +A per-operation override (`idempotentOperationIDs`) then corrects cases the +method heuristic gets wrong: `MemoService_SetMemoAttachments` and +`MemoService_SetMemoRelations` are PATCH but declaratively replace the full set +on a memo, so they report `IdempotentHint: true`. + `OpenWorldHint` is `false` for all tools. Annotations are client hints; they do not replace API authorization. diff --git a/server/router/mcp/catalog.go b/server/router/mcp/catalog.go index aa6fdb74..44e1caaa 100644 --- a/server/router/mcp/catalog.go +++ b/server/router/mcp/catalog.go @@ -26,6 +26,10 @@ var curatedOperationIDs = []string{ "AttachmentService_ListAttachments", "AttachmentService_GetAttachment", "AttachmentService_DeleteAttachment", + "ShortcutService_ListShortcuts", + // The only allowed auth/identity operation: a read-only "whoami" so agents + // can resolve the current user (e.g. for ShortcutService_ListShortcuts). + "AuthService_GetCurrentUser", } type registeredOperation struct { @@ -74,7 +78,7 @@ func buildToolFromOperation(operation *openAPIOperation) (*sdkmcp.Tool, *registe Description: operation.Description, InputSchema: inputSchema, OutputSchema: outputSchemaForOperation(operation), - Annotations: annotationsForMethod(operation.Method, title), + Annotations: annotationsForOperation(operation, title), } return tool, ®isteredOperation{ @@ -182,6 +186,26 @@ func extractSchemaDefs(schema jsonSchema) map[string]any { return defs } +// idempotentOperationIDs lists operations whose idempotency the HTTP-method +// heuristic gets wrong. The "Set*" operations declaratively replace the full +// set on a memo, so repeating an identical call converges to the same state — +// idempotent — even though they are served over PATCH (which the heuristic +// treats as non-idempotent). +var idempotentOperationIDs = map[string]bool{ + "MemoService_SetMemoAttachments": true, + "MemoService_SetMemoRelations": true, +} + +// annotationsForOperation derives the method-based annotations and then applies +// per-operation overrides that the HTTP method alone cannot express. +func annotationsForOperation(operation *openAPIOperation, title string) *sdkmcp.ToolAnnotations { + annotations := annotationsForMethod(operation.Method, title) + if idempotentOperationIDs[operation.OperationID] { + annotations.IdempotentHint = true + } + return annotations +} + func annotationsForMethod(method string, title string) *sdkmcp.ToolAnnotations { openWorld := false destructive := false diff --git a/server/router/mcp/catalog_test.go b/server/router/mcp/catalog_test.go index 46492dd2..1b36b112 100644 --- a/server/router/mcp/catalog_test.go +++ b/server/router/mcp/catalog_test.go @@ -9,11 +9,15 @@ import ( ) func TestCuratedOperationIDsStayMemoFocused(t *testing.T) { - require.Len(t, curatedOperationIDs, 17) + require.Len(t, curatedOperationIDs, 19) for _, operationID := range curatedOperationIDs { require.NotContains(t, operationID, "Admin") - require.NotContains(t, operationID, "AuthService_") + // AuthService_GetCurrentUser is the single allowed auth op (read-only + // "whoami"); the rest of the auth/identity surface stays off MCP. + if operationID != "AuthService_GetCurrentUser" { + require.NotContains(t, operationID, "AuthService_") + } require.NotContains(t, operationID, "UserService_") require.NotContains(t, operationID, "AIService_") require.NotContains(t, operationID, "IdentityProviderService_") @@ -100,6 +104,53 @@ func TestBuildToolFromOperationIncludesRequestBodySchema(t *testing.T) { require.NoError(t, err) } +func TestBuildToolFromOperationExposesCurrentUser(t *testing.T) { + spec, err := loadOpenAPISpec("../../../proto/gen/openapi.yaml") + require.NoError(t, err) + registry, err := buildOperationRegistry(spec) + require.NoError(t, err) + + tool, operation := buildToolFromOperation(registry["AuthService_GetCurrentUser"]) + require.Equal(t, "auth_get_current_user", tool.Name) + require.Equal(t, "GET", operation.Method) + require.True(t, tool.Annotations.ReadOnlyHint) +} + +func TestBuildToolFromOperationExposesListShortcuts(t *testing.T) { + spec, err := loadOpenAPISpec("../../../proto/gen/openapi.yaml") + require.NoError(t, err) + registry, err := buildOperationRegistry(spec) + require.NoError(t, err) + + tool, operation := buildToolFromOperation(registry["ShortcutService_ListShortcuts"]) + require.Equal(t, "shortcut_list_shortcuts", tool.Name) + require.Equal(t, "GET", operation.Method) + require.True(t, tool.Annotations.ReadOnlyHint) + + input, ok := tool.InputSchema.(jsonSchema) + require.True(t, ok) + properties, ok := input["properties"].(map[string]any) + require.True(t, ok) + require.Contains(t, properties, "user") +} + +func TestBuildToolFromOperationMarksSetOperationsIdempotent(t *testing.T) { + spec, err := loadOpenAPISpec("../../../proto/gen/openapi.yaml") + require.NoError(t, err) + registry, err := buildOperationRegistry(spec) + require.NoError(t, err) + + for _, operationID := range []string{"MemoService_SetMemoAttachments", "MemoService_SetMemoRelations"} { + tool, operation := buildToolFromOperation(registry[operationID]) + require.Equal(t, "PATCH", operation.Method, operationID) + // PATCH is non-idempotent by the method heuristic, but the per-operation + // override restores the declarative "set" semantics. + require.True(t, tool.Annotations.IdempotentHint, operationID) + require.False(t, tool.Annotations.ReadOnlyHint, operationID) + require.False(t, *tool.Annotations.DestructiveHint, operationID) + } +} + func TestBuildCuratedToolsHasUniqueNames(t *testing.T) { spec, err := loadOpenAPISpec("../../../proto/gen/openapi.yaml") require.NoError(t, err) diff --git a/server/router/mcp/evals/README.md b/server/router/mcp/evals/README.md new file mode 100644 index 00000000..16d50c77 --- /dev/null +++ b/server/router/mcp/evals/README.md @@ -0,0 +1,67 @@ +# MCP Evaluations + +Task-level evaluations for the memos MCP server. Where the `*_test.go` files +verify the server *plumbing* (schema resolution, tool naming, annotations), +these check the thing that actually matters for an MCP server: **can an LLM +accomplish realistic tasks by composing the tools?** They are the regression +net for tool descriptions and discoverability — e.g. a bad `filter` description +leaves the unit tests green but makes question 3/5/9 unanswerable. + +`memos_eval.xml` holds 10 question/answer pairs in the format used by the +mcp-builder skill. Each question is independent, read-only, requires multiple +tool calls, and has a single string-comparable answer. + +## Why a fresh seeded instance (not the public demo) + +Answers are pinned to the deterministic seed in +[`store/seed/sqlite/01__dump.sql`](../../../../store/seed/sqlite/01__dump.sql) +(10 memos — 7 top-level + 3 comments — 2 users, 12 reactions, no attachments, +no shortcuts). + +The public demo (`demo.usememos.com`) signs everyone into the **same shared +`demo` account**, so visitors continually add/edit/delete memos and reactions. +Its data has already diverged from the seed — do **not** evaluate against it. + +The seed uses **relative** timestamps (`strftime('now','-N days')`), so the +questions avoid absolute dates and rely only on relative ordering, counts, and +content, all of which are stable across re-seeds. + +## Running an evaluation + +1. Launch a throwaway demo-mode instance (SQLite, auto-seeded) on a free port: + + ```bash + go run ./cmd/memos --demo --driver sqlite \ + --port 8099 --data "$(mktemp -d)" \ + --instance-url http://localhost:8099 + ``` + +2. The MCP endpoint is `http://localhost:8099/mcp`. Authenticate with the seed's + demo personal access token: + + ``` + Authorization: Bearer memos_pat_demo + ``` + +3. Point an MCP client / eval harness at that endpoint and have the model answer + each ``, then string-compare against each ``. + + Quick manual check of a single tool call: + + ```bash + curl -s -X POST http://localhost:8099/mcp \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json, text/event-stream' \ + -H 'Authorization: Bearer memos_pat_demo' \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"memo_list_memos","arguments":{"filter":"pinned == true"}}}' + ``` + +## Notes for whoever extends this + +- The CEL `tag` field does **not** support `==`; filter tags with + `"work" in tags` (or `tags.exists(t, t == "work")`), not `tag == "work"`. +- `memo_list_memos` returns only top-level memos; comments are reached via + `memo_list_memo_comments`. +- The seed defines no shortcuts and no attachments, so `shortcut_list_shortcuts` + and the attachment tools return empty sets against a fresh seed. Add seed rows + before writing questions that depend on them. diff --git a/server/router/mcp/evals/memos_eval.xml b/server/router/mcp/evals/memos_eval.xml new file mode 100644 index 00000000..c762a558 --- /dev/null +++ b/server/router/mcp/evals/memos_eval.xml @@ -0,0 +1,62 @@ + + + + + Look at every memo in the instance and add up all the emoji reactions across them. What is the total number of reactions? + 12 + + + How many memos are pinned? + 2 + + + Considering only the memos that are NOT pinned, one of them has more reactions than any other. That memo is about a book the author started reading. What is the title of that book? + Deep Work + + + Two memos are pinned. One contains external hyperlinks and the other does not. Give the single tag of the pinned memo that contains hyperlinks. + sponsors + + + How many memos contain at least one fenced code block? + 2 + + + How many distinct users have authored the memos in this instance? + 2 + + + Find the memo that is a cheat sheet of git commands. How many comments does it have? + 2 + + + Find the memo containing a movie watchlist. What is the username of the user who created it? + alice + + + Exactly one memo is a travel bucket list. It names a Nordic country the author plans to visit in winter to see the Northern Lights. Which country? + Iceland + + + Using the authenticated session, what is the username (login name) of the account these memos belong to? + demo + + diff --git a/web/src/types/proto/api/v1/memo_service_pb.ts b/web/src/types/proto/api/v1/memo_service_pb.ts index 7a5b294c..636f27f6 100644 --- a/web/src/types/proto/api/v1/memo_service_pb.ts +++ b/web/src/types/proto/api/v1/memo_service_pb.ts @@ -125,6 +125,8 @@ export type Memo = Message<"memos.api.v1.Memo"> & { /** * The visibility of the memo. + * One of PRIVATE (creator only), PROTECTED (signed-in users), or + * PUBLIC (anyone). Defaults to PRIVATE on creation when unspecified. * * @generated from field: memos.api.v1.Visibility visibility = 9; */ @@ -338,16 +340,28 @@ export type ListMemosRequest = Message<"memos.api.v1.ListMemosRequest"> & { * Default to "create_time desc". * Supports comma-separated list of fields following AIP-132. * Example: "pinned desc, create_time desc" or "update_time asc" - * Supported fields: pinned, create_time, update_time, name + * Supported fields: pinned, create_time, update_time, name. + * Note: order_by uses create_time / update_time, while the filter + * expression uses created_ts / updated_ts for the same timestamps. * * @generated from field: string order_by = 4; */ orderBy: string; /** - * Optional. Filter to apply to the list results. - * Filter is a CEL expression to filter memos. - * Refer to `Shortcut.filter`. + * Optional. A CEL expression to filter memos. Combine terms with && and ||. + * Available fields: + * content (string), creator (string, e.g. "users/1"), + * created_ts / updated_ts (timestamp), pinned (bool), + * visibility (string: PRIVATE | PROTECTED | PUBLIC), + * tags (list; match with `"work" in tags`, not `tag == "work"`), + * has_task_list / has_link / has_code / has_incomplete_tasks (bool). + * Note: the time fields here are created_ts / updated_ts, which differ from + * the create_time / update_time names used by order_by. + * Examples: + * pinned == true && visibility == "PUBLIC" + * tags.exists(t, t == "urgent") + * content.contains("roadmap") && created_ts > now - duration("168h") * * @generated from field: string filter = 5; */ @@ -1193,6 +1207,8 @@ export const LinkMetadataSchema: GenMessage = /*@__PURE__*/ messageDesc(file_api_v1_memo_service, 32); /** + * Visibility controls who can read a memo. + * * @generated from enum memos.api.v1.Visibility */ export enum Visibility { @@ -1202,16 +1218,22 @@ export enum Visibility { VISIBILITY_UNSPECIFIED = 0, /** + * PRIVATE: only the creator can read the memo. + * * @generated from enum value: PRIVATE = 1; */ PRIVATE = 1, /** + * PROTECTED: signed-in users of the instance can read the memo. + * * @generated from enum value: PROTECTED = 2; */ PROTECTED = 2, /** + * PUBLIC: anyone, including anonymous visitors, can read the memo. + * * @generated from enum value: PUBLIC = 3; */ PUBLIC = 3, @@ -1228,7 +1250,9 @@ export const VisibilitySchema: GenEnum = /*@__PURE__*/ */ export const MemoService: GenService<{ /** - * CreateMemo creates a memo. + * CreateMemo creates a memo. The request body is a Memo; set its content + * (Markdown) and visibility (PRIVATE | PROTECTED | PUBLIC, default PRIVATE). + * The memo is owned by the authenticated user; requires authentication. * * @generated from rpc memos.api.v1.MemoService.CreateMemo */ @@ -1278,7 +1302,9 @@ export const MemoService: GenService<{ output: typeof EmptySchema; }, /** - * SetMemoAttachments sets attachments for a memo. + * SetMemoAttachments replaces the full set of attachments on a memo with the + * provided list (not an append). Pass the complete desired set; an empty list + * clears all attachments. Idempotent. * * @generated from rpc memos.api.v1.MemoService.SetMemoAttachments */ @@ -1298,7 +1324,9 @@ export const MemoService: GenService<{ output: typeof ListMemoAttachmentsResponseSchema; }, /** - * SetMemoRelations sets relations for a memo. + * SetMemoRelations replaces the full set of relations on a memo with the + * provided list (not an append). Pass the complete desired set; an empty list + * clears all relations. Idempotent. * * @generated from rpc memos.api.v1.MemoService.SetMemoRelations */ @@ -1348,7 +1376,8 @@ export const MemoService: GenService<{ output: typeof ListMemoReactionsResponseSchema; }, /** - * UpsertMemoReaction upserts a reaction for a memo. + * UpsertMemoReaction adds or updates the authenticated user's reaction on a + * memo. The reaction's content_id is the memo's resource name (memos/{memo}). * * @generated from rpc memos.api.v1.MemoService.UpsertMemoReaction */ diff --git a/web/src/types/proto/api/v1/shortcut_service_pb.ts b/web/src/types/proto/api/v1/shortcut_service_pb.ts index 4121a494..19b000ba 100644 --- a/web/src/types/proto/api/v1/shortcut_service_pb.ts +++ b/web/src/types/proto/api/v1/shortcut_service_pb.ts @@ -38,7 +38,8 @@ export type Shortcut = Message<"memos.api.v1.Shortcut"> & { title: string; /** - * The filter expression for the shortcut. + * The CEL filter expression for the shortcut, using the same grammar as the + * ListMemos `filter` argument. Reuse it by passing this value to ListMemos. * * @generated from field: string filter = 3; */ @@ -196,7 +197,9 @@ export const DeleteShortcutRequestSchema: GenMessage = /* */ export const ShortcutService: GenService<{ /** - * ListShortcuts returns a list of shortcuts for a user. + * ListShortcuts returns a user's saved shortcuts. Each shortcut is a named, + * reusable CEL filter (see Shortcut.filter); pass its filter string directly + * to the ListMemos `filter` argument to reuse a saved view. * * @generated from rpc memos.api.v1.ShortcutService.ListShortcuts */