e071084ebe
govulncheck / govulncheck (push) Waiting to run
Harness (E2E) / Harnesses (mock LLM) (push) Waiting to run
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Waiting to run
Lint / golangci-lint (push) Waiting to run
Run Tests / Unit Tests (push) Waiting to run
Run Tests / Etcd Integration Tests (push) Waiting to run
775 lines
22 KiB
Go
775 lines
22 KiB
Go
// Platform example: AI agents interacting with a real microservices platform.
|
|
//
|
|
// This example mirrors the micro/blog platform (https://github.com/micro/blog)
|
|
// — a microblogging platform built on Go Micro with Users, Posts, Comments,
|
|
// and Mail services. It demonstrates how existing microservices become
|
|
// AI-accessible through MCP with zero changes to business logic.
|
|
//
|
|
// The services run as a single binary for convenience. In production,
|
|
// each would be a separate process discovered via the registry.
|
|
//
|
|
// Run:
|
|
//
|
|
// go run .
|
|
//
|
|
// MCP tools: http://localhost:3001/mcp/tools
|
|
//
|
|
// Agent scenarios:
|
|
//
|
|
// "Sign me up as alice with password secret123"
|
|
// "Log in as alice and write a blog post about Go concurrency"
|
|
// "List all posts and comment on the first one"
|
|
// "Send a welcome email to alice"
|
|
// "Tag the Go concurrency post with 'golang' and 'tutorial'"
|
|
// "Show me alice's profile and all her posts"
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"go-micro.dev/v6"
|
|
"go-micro.dev/v6/gateway/mcp"
|
|
"go-micro.dev/v6/server"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Users service — account registration, login, profiles
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type User struct {
|
|
ID string `json:"id" description:"Unique user identifier"`
|
|
Name string `json:"name" description:"Display name"`
|
|
Status string `json:"status" description:"Bio or status message"`
|
|
CreatedAt int64 `json:"created_at" description:"Unix timestamp of account creation"`
|
|
}
|
|
|
|
type SignupRequest struct {
|
|
Name string `json:"name" description:"Username (required, 3-20 characters)"`
|
|
Password string `json:"password" description:"Password (required, minimum 6 characters)"`
|
|
}
|
|
type SignupResponse struct {
|
|
User *User `json:"user" description:"The newly created user account"`
|
|
Token string `json:"token" description:"Session token for authenticated requests"`
|
|
}
|
|
|
|
type LoginRequest struct {
|
|
Name string `json:"name" description:"Username"`
|
|
Password string `json:"password" description:"Password"`
|
|
}
|
|
type LoginResponse struct {
|
|
User *User `json:"user" description:"The authenticated user"`
|
|
Token string `json:"token" description:"Session token for authenticated requests"`
|
|
}
|
|
|
|
type GetProfileRequest struct {
|
|
ID string `json:"id" description:"User ID to look up"`
|
|
}
|
|
type GetProfileResponse struct {
|
|
User *User `json:"user" description:"The user profile"`
|
|
}
|
|
|
|
type UpdateStatusRequest struct {
|
|
ID string `json:"id" description:"User ID"`
|
|
Status string `json:"status" description:"New bio or status message"`
|
|
}
|
|
type UpdateStatusResponse struct {
|
|
User *User `json:"user" description:"Updated user profile"`
|
|
}
|
|
|
|
type ListUsersRequest struct{}
|
|
type ListUsersResponse struct {
|
|
Users []*User `json:"users" description:"All registered users"`
|
|
}
|
|
|
|
type Users struct {
|
|
mu sync.RWMutex
|
|
users map[string]*User
|
|
passwords map[string]string // name -> password (plaintext for demo only)
|
|
tokens map[string]string // token -> user ID
|
|
nextID int
|
|
}
|
|
|
|
func NewUsers() *Users {
|
|
return &Users{
|
|
users: make(map[string]*User),
|
|
passwords: make(map[string]string),
|
|
tokens: make(map[string]string),
|
|
}
|
|
}
|
|
|
|
// Signup creates a new user account and returns a session token.
|
|
// The username must be unique. Use the returned token for authenticated operations.
|
|
//
|
|
// @example {"name": "alice", "password": "secret123"}
|
|
func (s *Users) Signup(ctx context.Context, req *SignupRequest, rsp *SignupResponse) error {
|
|
if req.Name == "" || len(req.Name) < 3 {
|
|
return fmt.Errorf("name must be at least 3 characters")
|
|
}
|
|
if req.Password == "" || len(req.Password) < 6 {
|
|
return fmt.Errorf("password must be at least 6 characters")
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
// Check uniqueness
|
|
for _, u := range s.users {
|
|
if strings.EqualFold(u.Name, req.Name) {
|
|
return fmt.Errorf("username %q is already taken", req.Name)
|
|
}
|
|
}
|
|
|
|
s.nextID++
|
|
user := &User{
|
|
ID: fmt.Sprintf("user-%d", s.nextID),
|
|
Name: req.Name,
|
|
CreatedAt: time.Now().Unix(),
|
|
}
|
|
s.users[user.ID] = user
|
|
s.passwords[req.Name] = req.Password
|
|
|
|
token := generateToken()
|
|
s.tokens[token] = user.ID
|
|
|
|
rsp.User = user
|
|
rsp.Token = token
|
|
return nil
|
|
}
|
|
|
|
// Login authenticates a user and returns a session token.
|
|
// Returns an error if the credentials are invalid.
|
|
//
|
|
// @example {"name": "alice", "password": "secret123"}
|
|
func (s *Users) Login(ctx context.Context, req *LoginRequest, rsp *LoginResponse) error {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
pass, ok := s.passwords[req.Name]
|
|
if !ok || pass != req.Password {
|
|
return fmt.Errorf("invalid username or password")
|
|
}
|
|
|
|
// Find user by name
|
|
for _, u := range s.users {
|
|
if u.Name == req.Name {
|
|
token := generateToken()
|
|
s.tokens[token] = u.ID
|
|
rsp.User = u
|
|
rsp.Token = token
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("user not found")
|
|
}
|
|
|
|
// GetProfile retrieves a user's public profile by ID.
|
|
//
|
|
// @example {"id": "user-1"}
|
|
func (s *Users) GetProfile(ctx context.Context, req *GetProfileRequest, rsp *GetProfileResponse) error {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
u, ok := s.users[req.ID]
|
|
if !ok {
|
|
return fmt.Errorf("user %s not found", req.ID)
|
|
}
|
|
rsp.User = u
|
|
return nil
|
|
}
|
|
|
|
// UpdateStatus sets a user's bio or status message.
|
|
//
|
|
// @example {"id": "user-1", "status": "Writing about Go and microservices"}
|
|
func (s *Users) UpdateStatus(ctx context.Context, req *UpdateStatusRequest, rsp *UpdateStatusResponse) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
u, ok := s.users[req.ID]
|
|
if !ok {
|
|
return fmt.Errorf("user %s not found", req.ID)
|
|
}
|
|
u.Status = req.Status
|
|
rsp.User = u
|
|
return nil
|
|
}
|
|
|
|
// List returns all registered users on the platform.
|
|
//
|
|
// @example {}
|
|
func (s *Users) List(ctx context.Context, req *ListUsersRequest, rsp *ListUsersResponse) error {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
for _, u := range s.users {
|
|
rsp.Users = append(rsp.Users, u)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Posts service — blog posts with markdown and tags
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type Post struct {
|
|
ID string `json:"id" description:"Unique post identifier"`
|
|
Title string `json:"title" description:"Post title"`
|
|
Content string `json:"content" description:"Post body in markdown"`
|
|
AuthorID string `json:"author_id" description:"ID of the post author"`
|
|
AuthorName string `json:"author_name" description:"Display name of the author"`
|
|
Tags []string `json:"tags,omitempty" description:"Post tags for categorization"`
|
|
CreatedAt int64 `json:"created_at" description:"Unix timestamp of creation"`
|
|
UpdatedAt int64 `json:"updated_at" description:"Unix timestamp of last update"`
|
|
}
|
|
|
|
type CreatePostRequest struct {
|
|
Title string `json:"title" description:"Post title (required)"`
|
|
Content string `json:"content" description:"Post body in markdown (required)"`
|
|
AuthorID string `json:"author_id" description:"Author's user ID (required)"`
|
|
AuthorName string `json:"author_name" description:"Author's display name (required)"`
|
|
}
|
|
type CreatePostResponse struct {
|
|
Post *Post `json:"post" description:"The newly created post"`
|
|
}
|
|
|
|
type ReadPostRequest struct {
|
|
ID string `json:"id" description:"Post ID to retrieve"`
|
|
}
|
|
type ReadPostResponse struct {
|
|
Post *Post `json:"post" description:"The requested post"`
|
|
}
|
|
|
|
type UpdatePostRequest struct {
|
|
ID string `json:"id" description:"Post ID to update (required)"`
|
|
Title string `json:"title" description:"New title"`
|
|
Content string `json:"content" description:"New content in markdown"`
|
|
}
|
|
type UpdatePostResponse struct {
|
|
Post *Post `json:"post" description:"The updated post"`
|
|
}
|
|
|
|
type DeletePostRequest struct {
|
|
ID string `json:"id" description:"Post ID to delete"`
|
|
}
|
|
type DeletePostResponse struct {
|
|
Message string `json:"message" description:"Confirmation message"`
|
|
}
|
|
|
|
type ListPostsRequest struct {
|
|
AuthorID string `json:"author_id,omitempty" description:"Filter by author ID (optional)"`
|
|
}
|
|
type ListPostsResponse struct {
|
|
Posts []*Post `json:"posts" description:"Posts in reverse chronological order"`
|
|
Total int `json:"total" description:"Total number of matching posts"`
|
|
}
|
|
|
|
type TagPostRequest struct {
|
|
PostID string `json:"post_id" description:"Post to tag"`
|
|
Tag string `json:"tag" description:"Tag to add (lowercase, no spaces)"`
|
|
}
|
|
type TagPostResponse struct {
|
|
Post *Post `json:"post" description:"Post with updated tags"`
|
|
}
|
|
|
|
type UntagPostRequest struct {
|
|
PostID string `json:"post_id" description:"Post to untag"`
|
|
Tag string `json:"tag" description:"Tag to remove"`
|
|
}
|
|
type UntagPostResponse struct {
|
|
Post *Post `json:"post" description:"Post with updated tags"`
|
|
}
|
|
|
|
type ListTagsRequest struct{}
|
|
type ListTagsResponse struct {
|
|
Tags []string `json:"tags" description:"All tags in use, sorted alphabetically"`
|
|
}
|
|
|
|
type Posts struct {
|
|
mu sync.RWMutex
|
|
posts map[string]*Post
|
|
nextID int
|
|
}
|
|
|
|
func NewPosts() *Posts {
|
|
return &Posts{posts: make(map[string]*Post)}
|
|
}
|
|
|
|
// Create publishes a new blog post. Title, content, author_id, and author_name
|
|
// are required. Content supports markdown formatting.
|
|
//
|
|
// @example {"title": "Getting Started with Go Micro", "content": "Go Micro makes it easy to build microservices...", "author_id": "user-1", "author_name": "alice"}
|
|
func (s *Posts) Create(ctx context.Context, req *CreatePostRequest, rsp *CreatePostResponse) error {
|
|
if req.Title == "" {
|
|
return fmt.Errorf("title is required")
|
|
}
|
|
if req.Content == "" {
|
|
return fmt.Errorf("content is required")
|
|
}
|
|
if req.AuthorID == "" {
|
|
return fmt.Errorf("author_id is required")
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.nextID++
|
|
now := time.Now().Unix()
|
|
post := &Post{
|
|
ID: fmt.Sprintf("post-%d", s.nextID),
|
|
Title: req.Title,
|
|
Content: req.Content,
|
|
AuthorID: req.AuthorID,
|
|
AuthorName: req.AuthorName,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
s.posts[post.ID] = post
|
|
rsp.Post = post
|
|
return nil
|
|
}
|
|
|
|
// Read retrieves a single blog post by ID.
|
|
//
|
|
// @example {"id": "post-1"}
|
|
func (s *Posts) Read(ctx context.Context, req *ReadPostRequest, rsp *ReadPostResponse) error {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
p, ok := s.posts[req.ID]
|
|
if !ok {
|
|
return fmt.Errorf("post %s not found", req.ID)
|
|
}
|
|
rsp.Post = p
|
|
return nil
|
|
}
|
|
|
|
// Update modifies a blog post's title and/or content.
|
|
// Only non-empty fields are updated.
|
|
//
|
|
// @example {"id": "post-1", "title": "Updated Title", "content": "New content here..."}
|
|
func (s *Posts) Update(ctx context.Context, req *UpdatePostRequest, rsp *UpdatePostResponse) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
p, ok := s.posts[req.ID]
|
|
if !ok {
|
|
return fmt.Errorf("post %s not found", req.ID)
|
|
}
|
|
if req.Title != "" {
|
|
p.Title = req.Title
|
|
}
|
|
if req.Content != "" {
|
|
p.Content = req.Content
|
|
}
|
|
p.UpdatedAt = time.Now().Unix()
|
|
rsp.Post = p
|
|
return nil
|
|
}
|
|
|
|
// Delete removes a blog post permanently.
|
|
//
|
|
// @example {"id": "post-1"}
|
|
func (s *Posts) Delete(ctx context.Context, req *DeletePostRequest, rsp *DeletePostResponse) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
if _, ok := s.posts[req.ID]; !ok {
|
|
return fmt.Errorf("post %s not found", req.ID)
|
|
}
|
|
delete(s.posts, req.ID)
|
|
rsp.Message = fmt.Sprintf("post %s deleted", req.ID)
|
|
return nil
|
|
}
|
|
|
|
// List returns blog posts in reverse chronological order.
|
|
// Optionally filter by author_id to see a specific user's posts.
|
|
//
|
|
// @example {"author_id": "user-1"}
|
|
func (s *Posts) List(ctx context.Context, req *ListPostsRequest, rsp *ListPostsResponse) error {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
for _, p := range s.posts {
|
|
if req.AuthorID != "" && p.AuthorID != req.AuthorID {
|
|
continue
|
|
}
|
|
rsp.Posts = append(rsp.Posts, p)
|
|
}
|
|
sort.Slice(rsp.Posts, func(i, j int) bool {
|
|
return rsp.Posts[i].CreatedAt > rsp.Posts[j].CreatedAt
|
|
})
|
|
rsp.Total = len(rsp.Posts)
|
|
return nil
|
|
}
|
|
|
|
// TagPost adds a tag to a post. Tags are useful for categorization
|
|
// and discovery. Duplicate tags are ignored.
|
|
//
|
|
// @example {"post_id": "post-1", "tag": "golang"}
|
|
func (s *Posts) TagPost(ctx context.Context, req *TagPostRequest, rsp *TagPostResponse) error {
|
|
if req.PostID == "" || req.Tag == "" {
|
|
return fmt.Errorf("post_id and tag are required")
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
p, ok := s.posts[req.PostID]
|
|
if !ok {
|
|
return fmt.Errorf("post %s not found", req.PostID)
|
|
}
|
|
|
|
tag := strings.ToLower(strings.TrimSpace(req.Tag))
|
|
for _, t := range p.Tags {
|
|
if t == tag {
|
|
rsp.Post = p
|
|
return nil
|
|
}
|
|
}
|
|
p.Tags = append(p.Tags, tag)
|
|
p.UpdatedAt = time.Now().Unix()
|
|
rsp.Post = p
|
|
return nil
|
|
}
|
|
|
|
// UntagPost removes a tag from a post.
|
|
//
|
|
// @example {"post_id": "post-1", "tag": "golang"}
|
|
func (s *Posts) UntagPost(ctx context.Context, req *UntagPostRequest, rsp *UntagPostResponse) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
p, ok := s.posts[req.PostID]
|
|
if !ok {
|
|
return fmt.Errorf("post %s not found", req.PostID)
|
|
}
|
|
|
|
filtered := make([]string, 0, len(p.Tags))
|
|
for _, t := range p.Tags {
|
|
if t != req.Tag {
|
|
filtered = append(filtered, t)
|
|
}
|
|
}
|
|
p.Tags = filtered
|
|
p.UpdatedAt = time.Now().Unix()
|
|
rsp.Post = p
|
|
return nil
|
|
}
|
|
|
|
// ListTags returns all tags currently in use across all posts.
|
|
//
|
|
// @example {}
|
|
func (s *Posts) ListTags(ctx context.Context, req *ListTagsRequest, rsp *ListTagsResponse) error {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
seen := make(map[string]bool)
|
|
for _, p := range s.posts {
|
|
for _, t := range p.Tags {
|
|
seen[t] = true
|
|
}
|
|
}
|
|
for t := range seen {
|
|
rsp.Tags = append(rsp.Tags, t)
|
|
}
|
|
sort.Strings(rsp.Tags)
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Comments service — threaded comments on posts
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type Comment struct {
|
|
ID string `json:"id" description:"Unique comment identifier"`
|
|
PostID string `json:"post_id" description:"ID of the post this comment belongs to"`
|
|
Content string `json:"content" description:"Comment text"`
|
|
AuthorID string `json:"author_id" description:"ID of the comment author"`
|
|
AuthorName string `json:"author_name" description:"Display name of the author"`
|
|
CreatedAt int64 `json:"created_at" description:"Unix timestamp of creation"`
|
|
}
|
|
|
|
type CreateCommentRequest struct {
|
|
PostID string `json:"post_id" description:"Post to comment on (required)"`
|
|
Content string `json:"content" description:"Comment text (required)"`
|
|
AuthorID string `json:"author_id" description:"Author's user ID (required)"`
|
|
AuthorName string `json:"author_name" description:"Author's display name (required)"`
|
|
}
|
|
type CreateCommentResponse struct {
|
|
Comment *Comment `json:"comment" description:"The newly created comment"`
|
|
}
|
|
|
|
type ListCommentsRequest struct {
|
|
PostID string `json:"post_id,omitempty" description:"Filter by post ID (optional)"`
|
|
AuthorID string `json:"author_id,omitempty" description:"Filter by author ID (optional)"`
|
|
}
|
|
type ListCommentsResponse struct {
|
|
Comments []*Comment `json:"comments" description:"Matching comments"`
|
|
}
|
|
|
|
type DeleteCommentRequest struct {
|
|
ID string `json:"id" description:"Comment ID to delete"`
|
|
}
|
|
type DeleteCommentResponse struct {
|
|
Message string `json:"message" description:"Confirmation message"`
|
|
}
|
|
|
|
type Comments struct {
|
|
mu sync.RWMutex
|
|
comments []*Comment
|
|
nextID int
|
|
}
|
|
|
|
// Create adds a comment to a blog post. Post ID, content, author_id,
|
|
// and author_name are all required.
|
|
//
|
|
// @example {"post_id": "post-1", "content": "Great article! Very helpful.", "author_id": "user-2", "author_name": "bob"}
|
|
func (s *Comments) Create(ctx context.Context, req *CreateCommentRequest, rsp *CreateCommentResponse) error {
|
|
if req.PostID == "" {
|
|
return fmt.Errorf("post_id is required")
|
|
}
|
|
if req.Content == "" {
|
|
return fmt.Errorf("content is required")
|
|
}
|
|
if req.AuthorID == "" {
|
|
return fmt.Errorf("author_id is required")
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.nextID++
|
|
comment := &Comment{
|
|
ID: fmt.Sprintf("comment-%d", s.nextID),
|
|
PostID: req.PostID,
|
|
Content: req.Content,
|
|
AuthorID: req.AuthorID,
|
|
AuthorName: req.AuthorName,
|
|
CreatedAt: time.Now().Unix(),
|
|
}
|
|
s.comments = append(s.comments, comment)
|
|
rsp.Comment = comment
|
|
return nil
|
|
}
|
|
|
|
// List returns comments, optionally filtered by post or author.
|
|
// Use post_id to get all comments on a specific post.
|
|
//
|
|
// @example {"post_id": "post-1"}
|
|
func (s *Comments) List(ctx context.Context, req *ListCommentsRequest, rsp *ListCommentsResponse) error {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
|
|
for _, c := range s.comments {
|
|
if req.PostID != "" && c.PostID != req.PostID {
|
|
continue
|
|
}
|
|
if req.AuthorID != "" && c.AuthorID != req.AuthorID {
|
|
continue
|
|
}
|
|
rsp.Comments = append(rsp.Comments, c)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Delete removes a comment by ID.
|
|
//
|
|
// @example {"id": "comment-1"}
|
|
func (s *Comments) Delete(ctx context.Context, req *DeleteCommentRequest, rsp *DeleteCommentResponse) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i, c := range s.comments {
|
|
if c.ID == req.ID {
|
|
s.comments = append(s.comments[:i], s.comments[i+1:]...)
|
|
rsp.Message = fmt.Sprintf("comment %s deleted", req.ID)
|
|
return nil
|
|
}
|
|
}
|
|
return fmt.Errorf("comment %s not found", req.ID)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mail service — internal messaging between users
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type MailMessage struct {
|
|
ID string `json:"id" description:"Unique message identifier"`
|
|
From string `json:"from" description:"Sender username"`
|
|
To string `json:"to" description:"Recipient username"`
|
|
Subject string `json:"subject" description:"Message subject line"`
|
|
Body string `json:"body" description:"Message body text"`
|
|
Read bool `json:"read" description:"Whether the message has been read"`
|
|
CreatedAt int64 `json:"created_at" description:"Unix timestamp of when the message was sent"`
|
|
}
|
|
|
|
type SendMailRequest struct {
|
|
From string `json:"from" description:"Sender username (required)"`
|
|
To string `json:"to" description:"Recipient username (required)"`
|
|
Subject string `json:"subject" description:"Message subject (required)"`
|
|
Body string `json:"body" description:"Message body (required)"`
|
|
}
|
|
type SendMailResponse struct {
|
|
Message *MailMessage `json:"message" description:"The sent message"`
|
|
}
|
|
|
|
type ReadMailRequest struct {
|
|
User string `json:"user" description:"Username to read inbox for"`
|
|
}
|
|
type ReadMailResponse struct {
|
|
Messages []*MailMessage `json:"messages" description:"Inbox messages, newest first"`
|
|
}
|
|
|
|
type Mail struct {
|
|
mu sync.RWMutex
|
|
messages []*MailMessage
|
|
nextID int
|
|
}
|
|
|
|
// Send delivers a message to another user on the platform.
|
|
// Both sender and recipient are identified by username.
|
|
//
|
|
// @example {"from": "alice", "to": "bob", "subject": "Welcome!", "body": "Hey Bob, welcome to the platform!"}
|
|
func (s *Mail) Send(ctx context.Context, req *SendMailRequest, rsp *SendMailResponse) error {
|
|
if req.From == "" || req.To == "" {
|
|
return fmt.Errorf("from and to are required")
|
|
}
|
|
if req.Subject == "" {
|
|
return fmt.Errorf("subject is required")
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
s.nextID++
|
|
msg := &MailMessage{
|
|
ID: fmt.Sprintf("mail-%d", s.nextID),
|
|
From: req.From,
|
|
To: req.To,
|
|
Subject: req.Subject,
|
|
Body: req.Body,
|
|
CreatedAt: time.Now().Unix(),
|
|
}
|
|
s.messages = append(s.messages, msg)
|
|
rsp.Message = msg
|
|
return nil
|
|
}
|
|
|
|
// Read returns all messages in a user's inbox, newest first.
|
|
//
|
|
// @example {"user": "alice"}
|
|
func (s *Mail) Read(ctx context.Context, req *ReadMailRequest, rsp *ReadMailResponse) error {
|
|
if req.User == "" {
|
|
return fmt.Errorf("user is required")
|
|
}
|
|
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
|
|
for i := len(s.messages) - 1; i >= 0; i-- {
|
|
if s.messages[i].To == req.User {
|
|
s.messages[i].Read = true
|
|
rsp.Messages = append(rsp.Messages, s.messages[i])
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Main — wire up all services with MCP gateway
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func main() {
|
|
service := micro.NewService("platform",
|
|
micro.Address(":9090"),
|
|
mcp.WithMCP(":3001"),
|
|
)
|
|
service.Init()
|
|
|
|
users := NewUsers()
|
|
posts := NewPosts()
|
|
|
|
// Seed some demo data so agents have something to work with
|
|
seedData(users, posts)
|
|
|
|
service.Handle(users)
|
|
service.Handle(posts)
|
|
service.Handle(&Comments{})
|
|
service.Handle(&Mail{},
|
|
server.WithEndpointScopes("Mail.Send", "mail:write"),
|
|
server.WithEndpointScopes("Mail.Read", "mail:read"),
|
|
)
|
|
|
|
printBanner()
|
|
|
|
if err := service.Run(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func seedData(users *Users, posts *Posts) {
|
|
// Create demo users
|
|
var aliceRsp SignupResponse
|
|
users.Signup(context.Background(), &SignupRequest{
|
|
Name: "alice", Password: "secret123",
|
|
}, &aliceRsp)
|
|
|
|
var bobRsp SignupResponse
|
|
users.Signup(context.Background(), &SignupRequest{
|
|
Name: "bob", Password: "secret123",
|
|
}, &bobRsp)
|
|
|
|
// Alice writes a welcome post
|
|
var postRsp CreatePostResponse
|
|
posts.Create(context.Background(), &CreatePostRequest{
|
|
Title: "Welcome to the Platform",
|
|
Content: "This is the first post on our new blogging platform. Built with Go Micro, every service is automatically accessible to AI agents through MCP.",
|
|
AuthorID: aliceRsp.User.ID,
|
|
AuthorName: "alice",
|
|
}, &postRsp)
|
|
|
|
// Tag it
|
|
posts.TagPost(context.Background(), &TagPostRequest{
|
|
PostID: postRsp.Post.ID, Tag: "welcome",
|
|
}, &TagPostResponse{})
|
|
posts.TagPost(context.Background(), &TagPostRequest{
|
|
PostID: postRsp.Post.ID, Tag: "go-micro",
|
|
}, &TagPostResponse{})
|
|
}
|
|
|
|
func printBanner() {
|
|
fmt.Println()
|
|
fmt.Println(" Platform Demo — AI-Native Microservices")
|
|
fmt.Println()
|
|
fmt.Println(" Services: Users, Posts, Comments, Mail")
|
|
fmt.Println(" MCP Tools: http://localhost:3001/mcp/tools")
|
|
fmt.Println(" RPC: localhost:9090")
|
|
fmt.Println()
|
|
fmt.Println(" Seeded: alice (user-1), bob (user-2)")
|
|
fmt.Println(" 1 post with tags [welcome, go-micro]")
|
|
fmt.Println()
|
|
fmt.Println(" Try asking an agent:")
|
|
fmt.Println()
|
|
fmt.Println(` "Sign up a new user called carol"`)
|
|
fmt.Println(` "Log in as alice and write a post about Go concurrency patterns"`)
|
|
fmt.Println(` "List all posts and comment on the welcome post as bob"`)
|
|
fmt.Println(` "Tag alice's post with 'tutorial' and 'golang'"`)
|
|
fmt.Println(` "Send a mail from alice to bob welcoming him to the platform"`)
|
|
fmt.Println(` "Show me bob's inbox"`)
|
|
fmt.Println(` "List all users and show me all tags in use"`)
|
|
fmt.Println()
|
|
}
|
|
|
|
func generateToken() string {
|
|
b := make([]byte, 16)
|
|
rand.Read(b)
|
|
return hex.EncodeToString(b)
|
|
}
|