package cmd
import (
"errors"
"github.com/richardwooding/feed-mcp/mcpserver"
"github.com/richardwooding/feed-mcp/model"
"github.com/richardwooding/feed-mcp/store"
"time"
)
type RunCmd struct {
Transport string `name:"transport" default:"stdio" enum:"stdio,http-with-sse" help:"Transport to use for the MCP server."`
Feeds []string `arg:"" name:"feeds" help:"Feeds to list."`
ExpireAfter time.Duration `name:"expire-after" default:"1h" help:"Expire feeds after this duration."`
Timeout time.Duration `name:"timeout" default:"30s" help:"Timeout for fetching feed."`
}
func (c *RunCmd) Run(globals *model.Globals) error {
transport, err := model.ParseTransport(c.Transport)
if err != nil {
return err
}
if len(c.Feeds) == 0 {
return errors.New("no feeds specified")
}
feedStore, err := store.NewStore(store.Config{
Feeds: c.Feeds,
})
if err != nil {
return err
}
server, err := mcpserver.NewServer(mcpserver.Config{
Transport: transport,
AllFeedsGetter: feedStore,
FeedAndItemsGetter: feedStore,
})
if err != nil {
return err
}
return server.Run()
}
package main
import (
"github.com/alecthomas/kong"
"github.com/richardwooding/feed-mcp/cmd"
"github.com/richardwooding/feed-mcp/model"
)
type CLI struct {
model.Globals
Run cmd.RunCmd `cmd:"" help:"Run MCP Server"`
}
func main() {
cli := CLI{
Globals: model.Globals{
Version: model.VersionFlag("0.1.1"),
},
}
ctx := kong.Parse(&cli,
kong.Name("feed-mcp"),
kong.Description("A MCP server for RSS and Atom feeds"),
kong.UsageOnError(),
kong.ConfigureHelp(kong.HelpOptions{
Compact: true,
}),
kong.Vars{
"version": "0.1.11",
})
err := ctx.Run(&cli.Globals)
ctx.FatalIfErrorf(err)
}
package mcpserver
import (
"context"
"encoding/json"
"errors"
"github.com/gocolly/colly"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/richardwooding/feed-mcp/model"
)
type Config struct {
Transport model.Transport
AllFeedsGetter AllFeedsGetter
FeedAndItemsGetter FeedAndItemsGetter
}
type Server struct {
transport model.Transport
allFeedsGetter AllFeedsGetter
feedAndItemsGetter FeedAndItemsGetter
}
func NewServer(config Config) (*Server, error) {
if config.Transport == model.UndefinedTransport {
return nil, errors.New("transport must be specified")
}
if config.AllFeedsGetter == nil {
return nil, errors.New("AllFeedsGetter is required")
}
if config.FeedAndItemsGetter == nil {
return nil, errors.New("FeedAndItemsGetter is required")
}
return &Server{
transport: config.Transport,
allFeedsGetter: config.AllFeedsGetter,
feedAndItemsGetter: config.FeedAndItemsGetter,
}, nil
}
func (s *Server) Run() (err error) {
// Create a new MCP server
srv := server.NewMCPServer(
"RSS, Atom, and JSON Feed Server",
"1.0.0",
server.WithToolCapabilities(false),
server.WithRecovery(),
)
fetchLinkTool := mcp.NewTool("fetch_link",
mcp.WithDescription("Fetch link URL"),
mcp.WithString("link",
mcp.Required(),
mcp.Description("Link URL"),
),
)
srv.AddTool(fetchLinkTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
link, err := request.RequireString("link")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
c := colly.NewCollector()
var data []byte
c.OnResponse(func(response *colly.Response) {
data = response.Body
})
err = c.Visit(link)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultText(string(data)), nil
})
allFeedsTool := mcp.NewTool("all_syndication_feeds",
mcp.WithDescription("list available feedItem resources"),
)
srv.AddTool(allFeedsTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
feedResults, err := s.allFeedsGetter.GetAllFeeds(ctx)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
data, err := json.Marshal(feedResults)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
return mcp.NewToolResultText(string(data)), nil
})
getSyndicationFeedTool := mcp.NewTool("get_syndication_feed_items",
mcp.WithDescription("get syndication feed and items by id"),
mcp.WithString("id",
mcp.Required(),
mcp.Description("Feed ID"),
),
)
srv.AddTool(getSyndicationFeedTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
id, err := request.RequireString("id")
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
feedResult, err := s.feedAndItemsGetter.GetFeedAndItems(ctx, id)
if err != nil {
return mcp.NewToolResultError(err.Error()), nil
}
if data, err := json.Marshal(feedResult); err != nil {
return mcp.NewToolResultError(err.Error()), nil
} else {
return mcp.NewToolResultText(string(data)), nil
}
})
switch s.transport {
case model.StdioTransport:
err = server.ServeStdio(srv)
case model.HttpWithSSETransport:
httpServer := server.NewStreamableHTTPServer(srv)
err = httpServer.Start(":8080")
default:
return errors.New("unsupported transport")
}
return
}
package model
import (
"github.com/mmcdole/gofeed"
ext "github.com/mmcdole/gofeed/extensions"
"time"
)
type Feed struct {
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Link string `json:"link,omitempty"`
FeedLink string `json:"feedLink,omitempty"`
Links []string `json:"links,omitempty"`
Updated string `json:"updated,omitempty"`
UpdatedParsed *time.Time `json:"updatedParsed,omitempty"`
Published string `json:"published,omitempty"`
PublishedParsed *time.Time `json:"publishedParsed,omitempty"`
Authors []*gofeed.Person `json:"authors,omitempty"`
Language string `json:"language,omitempty"`
Image *gofeed.Image `json:"image,omitempty"`
Copyright string `json:"copyright,omitempty"`
Generator string `json:"generator,omitempty"`
Categories []string `json:"categories,omitempty"`
DublinCoreExt *ext.DublinCoreExtension `json:"dcExt,omitempty"`
ITunesExt *ext.ITunesFeedExtension `json:"itunesExt,omitempty"`
Extensions ext.Extensions `json:"extensions,omitempty"`
Custom map[string]string `json:"custom,omitempty"`
FeedType string `json:"feedType"`
FeedVersion string `json:"feedVersion"`
}
func FromGoFeed(inFeed *gofeed.Feed) *Feed {
if inFeed == nil {
return nil
}
return &Feed{
Title: inFeed.Title,
Description: inFeed.Description,
Link: inFeed.Link,
FeedLink: inFeed.FeedLink,
Links: inFeed.Links,
Updated: inFeed.Updated,
UpdatedParsed: inFeed.UpdatedParsed,
Published: inFeed.Published,
PublishedParsed: inFeed.PublishedParsed,
Authors: inFeed.Authors,
Language: inFeed.Language,
Image: inFeed.Image,
Copyright: inFeed.Copyright,
Generator: inFeed.Generator,
Categories: inFeed.Categories,
DublinCoreExt: inFeed.DublinCoreExt,
ITunesExt: inFeed.ITunesExt,
Extensions: inFeed.Extensions,
Custom: inFeed.Custom,
FeedType: inFeed.FeedType,
FeedVersion: inFeed.FeedVersion,
}
}
package model
import (
"errors"
)
var ErrInvalidTransport = errors.New("invalid transport")
type Transport uint8
const (
UndefinedTransport Transport = iota
StdioTransport
HttpWithSSETransport
)
func ParseTransport(transport string) (Transport, error) {
switch transport {
case "stdio":
return StdioTransport, nil
case "http-with-sse":
return HttpWithSSETransport, nil
default:
return UndefinedTransport, ErrInvalidTransport
}
}
func (t Transport) String() string {
switch t {
case StdioTransport:
return "stdio"
case HttpWithSSETransport:
return "http-with-sse"
default:
return "undefined"
}
}
package model
import (
"fmt"
"github.com/alecthomas/kong"
)
type VersionFlag string
func (v VersionFlag) Decode(ctx *kong.DecodeContext) error { return nil }
func (v VersionFlag) IsBool() bool { return true }
func (v VersionFlag) BeforeApply(app *kong.Kong, vars kong.Vars) error {
fmt.Println(vars["version"])
app.Exit(0)
return nil
}
package store
import (
"context"
"errors"
"fmt"
"github.com/dgraph-io/ristretto"
"github.com/eko/gocache/lib/v4/cache"
"github.com/eko/gocache/lib/v4/store"
ristretto_store "github.com/eko/gocache/store/ristretto/v4"
gonanoid "github.com/matoous/go-nanoid/v2"
"github.com/mmcdole/gofeed"
"github.com/richardwooding/feed-mcp/model"
"net/http"
"sync"
"time"
)
type Config struct {
Feeds []string
Timeout time.Duration
ExpireAfter time.Duration
HttpClient *http.Client
}
type Store struct {
feeds map[string]string
feedCacheManager *cache.LoadableCache[*gofeed.Feed]
}
func NewStore(config Config) (*Store, error) {
if len(config.Feeds) == 0 {
return nil, errors.New("at least one feedItem must be specified")
}
if config.Timeout == 0 {
config.Timeout = 30 * time.Second
}
if config.ExpireAfter == 0 {
config.ExpireAfter = 1 * time.Hour
}
ristrettoCache, err := ristretto.NewCache(&ristretto.Config{
NumCounters: 1000,
MaxCost: 100,
BufferItems: 64,
})
if err != nil {
return nil, err
}
ristrettoStore := ristretto_store.NewRistretto(ristrettoCache)
loadFunction := func(ctx context.Context, key any) (*gofeed.Feed, []store.Option, error) {
if url, ok := key.(string); ok {
fp := gofeed.NewParser()
if config.HttpClient != nil {
fp.Client = config.HttpClient
}
expireContext, cancel := context.WithTimeout(ctx, config.Timeout)
defer cancel()
feed, err := fp.ParseURLWithContext(url, expireContext)
if err != nil {
return nil, nil, err
}
return feed, []store.Option{store.WithExpiration(config.ExpireAfter)}, nil
} else {
return nil, nil, errors.New("invalid key type")
}
}
cacheManager := cache.NewLoadable[*gofeed.Feed](
loadFunction,
cache.New[*gofeed.Feed](ristrettoStore),
)
feeds := make(map[string]string, len(config.Feeds))
wg := sync.WaitGroup{}
for _, feedURL := range config.Feeds {
wg.Add(1)
go func(url string) {
defer wg.Done()
id, _ := gonanoid.New()
feeds[id] = url
_, _ = cacheManager.Get(context.Background(), url)
}(feedURL)
}
wg.Wait()
return &Store{
feeds: feeds,
feedCacheManager: cacheManager,
}, nil
}
func (s *Store) GetAllFeeds(ctx context.Context) ([]*model.FeedResult, error) {
results := make([]*model.FeedResult, len(s.feeds))
wg := &sync.WaitGroup{}
idx := 0
for id, url := range s.feeds {
wg.Add(1)
go func(idx int, id string, url string) {
defer wg.Done()
feed, err := s.feedCacheManager.Get(ctx, url)
if err != nil {
results[idx] = &model.FeedResult{
ID: id,
PublicURL: url,
FetchError: err.Error(),
}
} else {
results[idx] = &model.FeedResult{
ID: id,
PublicURL: url,
Title: feed.Title,
Feed: model.FromGoFeed(feed),
}
}
}(idx, id, url)
idx++
}
wg.Wait()
return results, nil
}
func (s *Store) GetFeedAndItems(ctx context.Context, id string) (*model.FeedAndItemsResult, error) {
if url, exists := s.feeds[id]; exists {
feed, err := s.feedCacheManager.Get(ctx, url)
if err != nil {
return &model.FeedAndItemsResult{
ID: id,
PublicURL: url,
FetchError: err.Error(),
}, nil
}
return &model.FeedAndItemsResult{
ID: id,
PublicURL: url,
Title: feed.Title,
Feed: model.FromGoFeed(feed),
Items: feed.Items,
}, nil
}
return nil, fmt.Errorf("feed with ID %s not found", id)
}