chore(mcp): improve tool discoverability, add orientation tools and evals

Make the OpenAPI-driven MCP surface more usable by agents, following the
mcp-builder guidance.

- Enrich proto descriptions (single source of truth, flows to OpenAPI + MCP
  tool descriptions): document the memo `filter` CEL grammar with fields and
  examples (replacing the dangling "Refer to Shortcut.filter"), clarify the
  created_ts/updated_ts vs create_time/update_time naming, the visibility
  enum, the declarative replace semantics of Set* ops, and steer tag filters
  to `"x" in tags` (not the unsupported `tag == "x"`).
- Mark SetMemoAttachments / SetMemoRelations idempotent via a per-operation
  override the HTTP-method heuristic can't express.
- Curate two read-only orientation tools: shortcut_list_shortcuts (surfaces
  reusable CEL filters) and auth_get_current_user (the single allowed
  auth/identity op, for resolving the current user); guard test updated to
  keep the rest of the auth/user surface excluded.
- Add a task-level evaluation suite (server/router/mcp/evals) with 10
  verified questions, pinned to the deterministic demo seed.
pull/6058/head
boojack 2 weeks ago
parent daa71d0456
commit 047175dbed

@ -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<string>; 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.

@ -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];
}

@ -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)

@ -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)

@ -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<string>; 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"`

@ -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)

@ -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

@ -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)

@ -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<string>; 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:

@ -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.

@ -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, &registeredOperation{
@ -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

@ -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)

@ -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 `<question>`, then string-compare against each `<answer>`.
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.

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Task-level evaluation for the memos MCP server.
These questions test whether an LLM can accomplish realistic tasks by
composing the curated MCP tools (memo_list_memos with CEL filters,
memo_list_memo_comments, memo_list_memo_reactions, shortcut_list_shortcuts,
auth_get_current_user, ...). They are NOT unit tests of the server plumbing —
see *_test.go for that.
Answers are pinned to the deterministic seed in
store/seed/sqlite/01__dump.sql and were verified by querying a fresh
demo-mode instance. Run against a freshly seeded instance ONLY (the public
demo at demo.usememos.com is a shared, mutable account and will not match).
See README.md in this directory for how to launch one.
Questions deliberately avoid absolute dates: the seed uses relative
timestamps (strftime('now','-N days')), so only relative ordering, counts,
and content are stable.
-->
<evaluation>
<qa_pair>
<question>Look at every memo in the instance and add up all the emoji reactions across them. What is the total number of reactions?</question>
<answer>12</answer>
</qa_pair>
<qa_pair>
<question>How many memos are pinned?</question>
<answer>2</answer>
</qa_pair>
<qa_pair>
<question>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?</question>
<answer>Deep Work</answer>
</qa_pair>
<qa_pair>
<question>Two memos are pinned. One contains external hyperlinks and the other does not. Give the single tag of the pinned memo that contains hyperlinks.</question>
<answer>sponsors</answer>
</qa_pair>
<qa_pair>
<question>How many memos contain at least one fenced code block?</question>
<answer>2</answer>
</qa_pair>
<qa_pair>
<question>How many distinct users have authored the memos in this instance?</question>
<answer>2</answer>
</qa_pair>
<qa_pair>
<question>Find the memo that is a cheat sheet of git commands. How many comments does it have?</question>
<answer>2</answer>
</qa_pair>
<qa_pair>
<question>Find the memo containing a movie watchlist. What is the username of the user who created it?</question>
<answer>alice</answer>
</qa_pair>
<qa_pair>
<question>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?</question>
<answer>Iceland</answer>
</qa_pair>
<qa_pair>
<question>Using the authenticated session, what is the username (login name) of the account these memos belong to?</question>
<answer>demo</answer>
</qa_pair>
</evaluation>

@ -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<string>; 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<LinkMetadata> = /*@__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<Visibility> = /*@__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
*/

@ -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<DeleteShortcutRequest> = /*
*/
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
*/

Loading…
Cancel
Save