examples: add a 'listeverything' client example, and reorganize

Add a client example that lists all features on a stdio server. Since
this is our first client example, organize examples by client / server
-- otherwise it is too difficult to find client examples.

For #33
This commit is contained in:
Rob Findley 2025-07-30 19:33:15 +00:00 • committed by Robert Findley
commit e00c485969
14 changed files with 65 additions and 0 deletions

View file

@ -0,0 +1,65 @@
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
// The listfeatures command lists all features of a stdio MCP server.
//
// Usage: listfeatures <command> [<args>]
//
// For example:
//
// listfeatures go run github.com/modelcontextprotocol/go-sdk/examples/server/hello
//
// or
//
// listfeatures npx @modelcontextprotocol/server-everything
package main
import (
"context"
"flag"
"fmt"
"iter"
"log"
"os"
"os/exec"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func main() {
flag.Parse()
args := flag.Args()
if len(args) == 0 {
fmt.Fprintf(os.Stderr, "Usage: listfeatures <command> [<args>]")
fmt.Fprintf(os.Stderr, "List all features for a stdio MCP server")
fmt.Fprintln(os.Stderr)
fmt.Fprintf(os.Stderr, "Example: listfeatures npx @modelcontextprotocol/server-everything")
os.Exit(2)
}
ctx := context.Background()
cmd := exec.Command(args[0], args[1:]...)
client := mcp.NewClient(&mcp.Implementation{Name: "mcp-client", Version: "v1.0.0"}, nil)
cs, err := client.Connect(ctx, mcp.NewCommandTransport(cmd))
if err != nil {
log.Fatal(err)
}
defer cs.Close()
printSection("tools", cs.Tools(ctx, nil), func(t *mcp.Tool) string { return t.Name })
printSection("resources", cs.Resources(ctx, nil), func(r *mcp.Resource) string { return r.Name })
printSection("resource templates", cs.ResourceTemplates(ctx, nil), func(r *mcp.ResourceTemplate) string { return r.Name })
printSection("prompts", cs.Prompts(ctx, nil), func(p *mcp.Prompt) string { return p.Name })
}
func printSection[T any](name string, features iter.Seq2[T, error], featName func(T) string) {
fmt.Printf("%s:\n", name)
for feat, err := range features {
if err != nil {
log.Fatal(err)
}
fmt.Printf("\t%s\n", featName(feat))
}
fmt.Println()
}

View file

@ -0,0 +1,49 @@
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package main
import (
"context"
"fmt"
"log"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// This example demonstrates the minimal code to declare and assign
// a CompletionHandler to an MCP Server's options.
func main() {
// Define your custom CompletionHandler logic.
myCompletionHandler := func(_ context.Context, _ *mcp.ServerSession, params *mcp.CompleteParams) (*mcp.CompleteResult, error) {
// In a real application, you'd implement actual completion logic here.
// For this example, we return a fixed set of suggestions.
var suggestions []string
switch params.Ref.Type {
case "ref/prompt":
suggestions = []string{"suggestion1", "suggestion2", "suggestion3"}
case "ref/resource":
suggestions = []string{"suggestion4", "suggestion5", "suggestion6"}
default:
return nil, fmt.Errorf("unrecognized content type %s", params.Ref.Type)
}
return &mcp.CompleteResult{
Completion: mcp.CompletionResultDetails{
HasMore: false,
Total: len(suggestions),
Values: suggestions,
},
}, nil
}
// Create the MCP Server instance and assign the handler.
// No server running, just showing the configuration.
_ = mcp.NewServer(&mcp.Implementation{Name: "server"}, &mcp.ServerOptions{
CompletionHandler: myCompletionHandler,
})
log.Println("MCP Server instance created with a CompletionHandler assigned (but not running).")
log.Println("This example demonstrates configuration, not live interaction.")
}

View file

@ -0,0 +1,109 @@
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package main
import (
"bufio"
"context"
"errors"
"io"
"log"
"os"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// IOTransport is a simplified implementation of a transport that communicates using
// newline-delimited JSON over an io.Reader and io.Writer. It is similar to ioTransport
// in transport.go and serves as a demonstration of how to implement a custom transport.
type IOTransport struct {
r *bufio.Reader
w io.Writer
}
// NewIOTransport creates a new IOTransport with the given io.Reader and io.Writer.
func NewIOTransport(r io.Reader, w io.Writer) *IOTransport {
return &IOTransport{
r: bufio.NewReader(r),
w: w,
}
}
// ioConn is a connection that uses newlines to delimit messages. It implements [mcp.Connection].
type ioConn struct {
r *bufio.Reader
w io.Writer
}
// Connect implements [mcp.Transport.Connect] by creating a new ioConn.
func (t *IOTransport) Connect(ctx context.Context) (mcp.Connection, error) {
return &ioConn{
r: t.r,
w: t.w,
}, nil
}
// Read implements [mcp.Connection.Read], assuming messages are newline-delimited JSON.
func (t *ioConn) Read(context.Context) (jsonrpc.Message, error) {
data, err := t.r.ReadBytes('\n')
if err != nil {
return nil, err
}
return jsonrpc.DecodeMessage(data[:len(data)-1])
}
// Write implements [mcp.Connection.Write], appending a newline delimiter after the message.
func (t *ioConn) Write(_ context.Context, msg jsonrpc.Message) error {
data, err := jsonrpc.EncodeMessage(msg)
if err != nil {
return err
}
_, err1 := t.w.Write(data)
_, err2 := t.w.Write([]byte{'\n'})
return errors.Join(err1, err2)
}
// Close implements [mcp.Connection.Close]. Since this is a simplified example, it is a no-op.
func (t *ioConn) Close() error {
return nil
}
// SessionID implements [mcp.Connection.SessionID]. Since this is a simplified example,
// it returns an empty session ID.
func (t *ioConn) SessionID() string {
return ""
}
// HiArgs is the argument type for the SayHi tool.
type HiArgs struct {
Name string `json:"name" mcp:"the name to say hi to"`
}
// SayHi is a tool handler that responds with a greeting.
func SayHi(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolParamsFor[HiArgs]) (*mcp.CallToolResultFor[struct{}], error) {
return &mcp.CallToolResultFor[struct{}]{
Content: []mcp.Content{
&mcp.TextContent{Text: "Hi " + params.Arguments.Name},
},
}, nil
}
func main() {
server := mcp.NewServer(&mcp.Implementation{Name: "greeter"}, nil)
mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi)
// Run the server with a custom IOTransport using stdio as the io.Reader and io.Writer.
transport := &IOTransport{
r: bufio.NewReader(os.Stdin),
w: os.Stdout,
}
err := server.Run(context.Background(), transport)
if err != nil {
log.Println("[ERROR]: Failed to run server:", err)
}
}

View file

@ -0,0 +1,90 @@
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"net/url"
"os"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
var httpAddr = flag.String("http", "", "if set, use streamable HTTP at this address, instead of stdin/stdout")
type HiArgs struct {
Name string `json:"name" jsonschema:"the name to say hi to"`
}
func SayHi(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolParamsFor[HiArgs]) (*mcp.CallToolResultFor[struct{}], error) {
return &mcp.CallToolResultFor[struct{}]{
Content: []mcp.Content{
&mcp.TextContent{Text: "Hi " + params.Arguments.Name},
},
}, nil
}
func PromptHi(ctx context.Context, ss *mcp.ServerSession, params *mcp.GetPromptParams) (*mcp.GetPromptResult, error) {
return &mcp.GetPromptResult{
Description: "Code review prompt",
Messages: []*mcp.PromptMessage{
{Role: "user", Content: &mcp.TextContent{Text: "Say hi to " + params.Arguments["name"]}},
},
}, nil
}
func main() {
flag.Parse()
server := mcp.NewServer(&mcp.Implementation{Name: "greeter"}, nil)
mcp.AddTool(server, &mcp.Tool{Name: "greet", Description: "say hi"}, SayHi)
server.AddPrompt(&mcp.Prompt{Name: "greet"}, PromptHi)
server.AddResource(&mcp.Resource{
Name: "info",
MIMEType: "text/plain",
URI: "embedded:info",
}, handleEmbeddedResource)
if *httpAddr != "" {
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return server
}, nil)
log.Printf("MCP handler listening at %s", *httpAddr)
http.ListenAndServe(*httpAddr, handler)
} else {
t := mcp.NewLoggingTransport(mcp.NewStdioTransport(), os.Stderr)
if err := server.Run(context.Background(), t); err != nil {
log.Printf("Server failed: %v", err)
}
}
}
var embeddedResources = map[string]string{
"info": "This is the hello example server.",
}
func handleEmbeddedResource(_ context.Context, _ *mcp.ServerSession, params *mcp.ReadResourceParams) (*mcp.ReadResourceResult, error) {
u, err := url.Parse(params.URI)
if err != nil {
return nil, err
}
if u.Scheme != "embedded" {
return nil, fmt.Errorf("wrong scheme: %q", u.Scheme)
}
key := u.Opaque
text, ok := embeddedResources[key]
if !ok {
return nil, fmt.Errorf("no embedded resource named %q", key)
}
return &mcp.ReadResourceResult{
Contents: []*mcp.ResourceContents{
{URI: params.URI, MIMEType: "text/plain", Text: text},
},
}, nil
}

View file

@ -0,0 +1,582 @@
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"slices"
"strings"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Entity represents a knowledge graph node with observations.
type Entity struct {
Name string `json:"name"`
EntityType string `json:"entityType"`
Observations []string `json:"observations"`
}
// Relation represents a directed edge between two entities.
type Relation struct {
From string `json:"from"`
To string `json:"to"`
RelationType string `json:"relationType"`
}
// Observation contains facts about an entity.
type Observation struct {
EntityName string `json:"entityName"`
Contents []string `json:"contents"`
Observations []string `json:"observations,omitempty"` // Used for deletion operations
}
// KnowledgeGraph represents the complete graph structure.
type KnowledgeGraph struct {
Entities []Entity `json:"entities"`
Relations []Relation `json:"relations"`
}
// store provides persistence interface for knowledge base data.
type store interface {
Read() ([]byte, error)
Write(data []byte) error
}
// memoryStore implements in-memory storage that doesn't persist across restarts.
type memoryStore struct {
data []byte
}
// Read returns the in-memory data.
func (ms *memoryStore) Read() ([]byte, error) {
return ms.data, nil
}
// Write stores data in memory.
func (ms *memoryStore) Write(data []byte) error {
ms.data = data
return nil
}
// fileStore implements file-based storage for persistent knowledge base.
type fileStore struct {
path string
}
// Read loads data from file, returning empty slice if file doesn't exist.
func (fs *fileStore) Read() ([]byte, error) {
data, err := os.ReadFile(fs.path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("failed to read file %s: %w", fs.path, err)
}
return data, nil
}
// Write saves data to file with 0600 permissions.
func (fs *fileStore) Write(data []byte) error {
if err := os.WriteFile(fs.path, data, 0600); err != nil {
return fmt.Errorf("failed to write file %s: %w", fs.path, err)
}
return nil
}
// knowledgeBase manages entities and relations with persistent storage.
type knowledgeBase struct {
s store
}
// kbItem represents a single item in persistent storage (entity or relation).
type kbItem struct {
Type string `json:"type"`
// Entity fields (when Type == "entity")
Name string `json:"name,omitempty"`
EntityType string `json:"entityType,omitempty"`
Observations []string `json:"observations,omitempty"`
// Relation fields (when Type == "relation")
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
RelationType string `json:"relationType,omitempty"`
}
// loadGraph deserializes the knowledge graph from storage.
func (k knowledgeBase) loadGraph() (KnowledgeGraph, error) {
data, err := k.s.Read()
if err != nil {
return KnowledgeGraph{}, fmt.Errorf("failed to read from store: %w", err)
}
if len(data) == 0 {
return KnowledgeGraph{}, nil
}
var items []kbItem
if err := json.Unmarshal(data, &items); err != nil {
return KnowledgeGraph{}, fmt.Errorf("failed to unmarshal from store: %w", err)
}
graph := KnowledgeGraph{}
for _, item := range items {
switch item.Type {
case "entity":
graph.Entities = append(graph.Entities, Entity{
Name: item.Name,
EntityType: item.EntityType,
Observations: item.Observations,
})
case "relation":
graph.Relations = append(graph.Relations, Relation{
From: item.From,
To: item.To,
RelationType: item.RelationType,
})
}
}
return graph, nil
}
// saveGraph serializes and persists the knowledge graph to storage.
func (k knowledgeBase) saveGraph(graph KnowledgeGraph) error {
items := make([]kbItem, 0, len(graph.Entities)+len(graph.Relations))
for _, entity := range graph.Entities {
items = append(items, kbItem{
Type: "entity",
Name: entity.Name,
EntityType: entity.EntityType,
Observations: entity.Observations,
})
}
for _, relation := range graph.Relations {
items = append(items, kbItem{
Type: "relation",
From: relation.From,
To: relation.To,
RelationType: relation.RelationType,
})
}
itemsJSON, err := json.Marshal(items)
if err != nil {
return fmt.Errorf("failed to marshal items: %w", err)
}
if err := k.s.Write(itemsJSON); err != nil {
return fmt.Errorf("failed to write to store: %w", err)
}
return nil
}
// createEntities adds new entities to the graph, skipping duplicates by name.
// It returns the new entities that were actually added.
func (k knowledgeBase) createEntities(entities []Entity) ([]Entity, error) {
graph, err := k.loadGraph()
if err != nil {
return nil, err
}
var newEntities []Entity
for _, entity := range entities {
if !slices.ContainsFunc(graph.Entities, func(e Entity) bool { return e.Name == entity.Name }) {
newEntities = append(newEntities, entity)
graph.Entities = append(graph.Entities, entity)
}
}
if err := k.saveGraph(graph); err != nil {
return nil, err
}
return newEntities, nil
}
// createRelations adds new relations to the graph, skipping exact duplicates.
// It returns the new relations that were actually added.
func (k knowledgeBase) createRelations(relations []Relation) ([]Relation, error) {
graph, err := k.loadGraph()
if err != nil {
return nil, err
}
var newRelations []Relation
for _, relation := range relations {
exists := slices.ContainsFunc(graph.Relations, func(r Relation) bool {
return r.From == relation.From &&
r.To == relation.To &&
r.RelationType == relation.RelationType
})
if !exists {
newRelations = append(newRelations, relation)
graph.Relations = append(graph.Relations, relation)
}
}
if err := k.saveGraph(graph); err != nil {
return nil, err
}
return newRelations, nil
}
// addObservations appends new observations to existing entities.
// It returns the new observations that were actually added.
func (k knowledgeBase) addObservations(observations []Observation) ([]Observation, error) {
graph, err := k.loadGraph()
if err != nil {
return nil, err
}
var results []Observation
for _, obs := range observations {
entityIndex := slices.IndexFunc(graph.Entities, func(e Entity) bool { return e.Name == obs.EntityName })
if entityIndex == -1 {
return nil, fmt.Errorf("entity with name %s not found", obs.EntityName)
}
var newObservations []string
for _, content := range obs.Contents {
if !slices.Contains(graph.Entities[entityIndex].Observations, content) {
newObservations = append(newObservations, content)
graph.Entities[entityIndex].Observations = append(graph.Entities[entityIndex].Observations, content)
}
}
results = append(results, Observation{
EntityName: obs.EntityName,
Contents: newObservations,
})
}
if err := k.saveGraph(graph); err != nil {
return nil, err
}
return results, nil
}
// deleteEntities removes entities and their associated relations.
func (k knowledgeBase) deleteEntities(entityNames []string) error {
graph, err := k.loadGraph()
if err != nil {
return err
}
// Create map for quick lookup
entitiesToDelete := make(map[string]bool)
for _, name := range entityNames {
entitiesToDelete[name] = true
}
// Filter entities using slices.DeleteFunc
graph.Entities = slices.DeleteFunc(graph.Entities, func(entity Entity) bool {
return entitiesToDelete[entity.Name]
})
// Filter relations using slices.DeleteFunc
graph.Relations = slices.DeleteFunc(graph.Relations, func(relation Relation) bool {
return entitiesToDelete[relation.From] || entitiesToDelete[relation.To]
})
return k.saveGraph(graph)
}
// deleteObservations removes specific observations from entities.
func (k knowledgeBase) deleteObservations(deletions []Observation) error {
graph, err := k.loadGraph()
if err != nil {
return err
}
for _, deletion := range deletions {
entityIndex := slices.IndexFunc(graph.Entities, func(e Entity) bool {
return e.Name == deletion.EntityName
})
if entityIndex == -1 {
continue
}
// Create a map for quick lookup
observationsToDelete := make(map[string]bool)
for _, observation := range deletion.Observations {
observationsToDelete[observation] = true
}
// Filter observations using slices.DeleteFunc
graph.Entities[entityIndex].Observations = slices.DeleteFunc(graph.Entities[entityIndex].Observations, func(observation string) bool {
return observationsToDelete[observation]
})
}
return k.saveGraph(graph)
}
// deleteRelations removes specific relations from the graph.
func (k knowledgeBase) deleteRelations(relations []Relation) error {
graph, err := k.loadGraph()
if err != nil {
return err
}
// Filter relations using slices.DeleteFunc and slices.ContainsFunc
graph.Relations = slices.DeleteFunc(graph.Relations, func(existingRelation Relation) bool {
return slices.ContainsFunc(relations, func(relationToDelete Relation) bool {
return existingRelation.From == relationToDelete.From &&
existingRelation.To == relationToDelete.To &&
existingRelation.RelationType == relationToDelete.RelationType
})
})
return k.saveGraph(graph)
}
// searchNodes filters entities and relations matching the query string.
func (k knowledgeBase) searchNodes(query string) (KnowledgeGraph, error) {
graph, err := k.loadGraph()
if err != nil {
return KnowledgeGraph{}, err
}
queryLower := strings.ToLower(query)
var filteredEntities []Entity
// Filter entities
for _, entity := range graph.Entities {
if strings.Contains(strings.ToLower(entity.Name), queryLower) ||
strings.Contains(strings.ToLower(entity.EntityType), queryLower) {
filteredEntities = append(filteredEntities, entity)
continue
}
// Check observations
for _, observation := range entity.Observations {
if strings.Contains(strings.ToLower(observation), queryLower) {
filteredEntities = append(filteredEntities, entity)
break
}
}
}
// Create map for quick entity lookup
filteredEntityNames := make(map[string]bool)
for _, entity := range filteredEntities {
filteredEntityNames[entity.Name] = true
}
// Filter relations
var filteredRelations []Relation
for _, relation := range graph.Relations {
if filteredEntityNames[relation.From] && filteredEntityNames[relation.To] {
filteredRelations = append(filteredRelations, relation)
}
}
return KnowledgeGraph{
Entities: filteredEntities,
Relations: filteredRelations,
}, nil
}
// openNodes returns entities with specified names and their interconnecting relations.
func (k knowledgeBase) openNodes(names []string) (KnowledgeGraph, error) {
graph, err := k.loadGraph()
if err != nil {
return KnowledgeGraph{}, err
}
// Create map for quick name lookup
nameSet := make(map[string]bool)
for _, name := range names {
nameSet[name] = true
}
// Filter entities
var filteredEntities []Entity
for _, entity := range graph.Entities {
if nameSet[entity.Name] {
filteredEntities = append(filteredEntities, entity)
}
}
// Create map for quick entity lookup
filteredEntityNames := make(map[string]bool)
for _, entity := range filteredEntities {
filteredEntityNames[entity.Name] = true
}
// Filter relations
var filteredRelations []Relation
for _, relation := range graph.Relations {
if filteredEntityNames[relation.From] && filteredEntityNames[relation.To] {
filteredRelations = append(filteredRelations, relation)
}
}
return KnowledgeGraph{
Entities: filteredEntities,
Relations: filteredRelations,
}, nil
}
func (k knowledgeBase) CreateEntities(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolParamsFor[CreateEntitiesArgs]) (*mcp.CallToolResultFor[CreateEntitiesResult], error) {
var res mcp.CallToolResultFor[CreateEntitiesResult]
entities, err := k.createEntities(params.Arguments.Entities)
if err != nil {
return nil, err
}
res.Content = []mcp.Content{
&mcp.TextContent{Text: "Entities created successfully"},
}
res.StructuredContent = CreateEntitiesResult{
Entities: entities,
}
return &res, nil
}
func (k knowledgeBase) CreateRelations(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolParamsFor[CreateRelationsArgs]) (*mcp.CallToolResultFor[CreateRelationsResult], error) {
var res mcp.CallToolResultFor[CreateRelationsResult]
relations, err := k.createRelations(params.Arguments.Relations)
if err != nil {
return nil, err
}
res.Content = []mcp.Content{
&mcp.TextContent{Text: "Relations created successfully"},
}
res.StructuredContent = CreateRelationsResult{
Relations: relations,
}
return &res, nil
}
func (k knowledgeBase) AddObservations(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolParamsFor[AddObservationsArgs]) (*mcp.CallToolResultFor[AddObservationsResult], error) {
var res mcp.CallToolResultFor[AddObservationsResult]
observations, err := k.addObservations(params.Arguments.Observations)
if err != nil {
return nil, err
}
res.Content = []mcp.Content{
&mcp.TextContent{Text: "Observations added successfully"},
}
res.StructuredContent = AddObservationsResult{
Observations: observations,
}
return &res, nil
}
func (k knowledgeBase) DeleteEntities(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolParamsFor[DeleteEntitiesArgs]) (*mcp.CallToolResultFor[struct{}], error) {
var res mcp.CallToolResultFor[struct{}]
err := k.deleteEntities(params.Arguments.EntityNames)
if err != nil {
return nil, err
}
res.Content = []mcp.Content{
&mcp.TextContent{Text: "Entities deleted successfully"},
}
return &res, nil
}
func (k knowledgeBase) DeleteObservations(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolParamsFor[DeleteObservationsArgs]) (*mcp.CallToolResultFor[struct{}], error) {
var res mcp.CallToolResultFor[struct{}]
err := k.deleteObservations(params.Arguments.Deletions)
if err != nil {
return nil, err
}
res.Content = []mcp.Content{
&mcp.TextContent{Text: "Observations deleted successfully"},
}
return &res, nil
}
func (k knowledgeBase) DeleteRelations(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolParamsFor[DeleteRelationsArgs]) (*mcp.CallToolResultFor[struct{}], error) {
var res mcp.CallToolResultFor[struct{}]
err := k.deleteRelations(params.Arguments.Relations)
if err != nil {
return nil, err
}
res.Content = []mcp.Content{
&mcp.TextContent{Text: "Relations deleted successfully"},
}
return &res, nil
}
func (k knowledgeBase) ReadGraph(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolParamsFor[struct{}]) (*mcp.CallToolResultFor[KnowledgeGraph], error) {
var res mcp.CallToolResultFor[KnowledgeGraph]
graph, err := k.loadGraph()
if err != nil {
return nil, err
}
res.Content = []mcp.Content{
&mcp.TextContent{Text: "Graph read successfully"},
}
res.StructuredContent = graph
return &res, nil
}
func (k knowledgeBase) SearchNodes(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolParamsFor[SearchNodesArgs]) (*mcp.CallToolResultFor[KnowledgeGraph], error) {
var res mcp.CallToolResultFor[KnowledgeGraph]
graph, err := k.searchNodes(params.Arguments.Query)
if err != nil {
return nil, err
}
res.Content = []mcp.Content{
&mcp.TextContent{Text: "Nodes searched successfully"},
}
res.StructuredContent = graph
return &res, nil
}
func (k knowledgeBase) OpenNodes(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolParamsFor[OpenNodesArgs]) (*mcp.CallToolResultFor[KnowledgeGraph], error) {
var res mcp.CallToolResultFor[KnowledgeGraph]
graph, err := k.openNodes(params.Arguments.Names)
if err != nil {
return nil, err
}
res.Content = []mcp.Content{
&mcp.TextContent{Text: "Nodes opened successfully"},
}
res.StructuredContent = graph
return &res, nil
}

View file

@ -0,0 +1,703 @@
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"testing"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// stores provides test factories for both storage implementations.
func stores() map[string]func(t *testing.T) store {
return map[string]func(t *testing.T) store{
"file": func(t *testing.T) store {
tempDir, err := os.MkdirTemp("", "kb-test-file-*")
if err != nil {
t.Fatalf("failed to create temp dir: %v", err)
}
t.Cleanup(func() { os.RemoveAll(tempDir) })
return &fileStore{path: filepath.Join(tempDir, "test-memory.json")}
},
"memory": func(t *testing.T) store {
return &memoryStore{}
},
}
}
// TestKnowledgeBaseOperations verifies CRUD operations work correctly.
func TestKnowledgeBaseOperations(t *testing.T) {
for name, newStore := range stores() {
t.Run(name, func(t *testing.T) {
s := newStore(t)
kb := knowledgeBase{s: s}
// Verify empty graph loads correctly
graph, err := kb.loadGraph()
if err != nil {
t.Fatalf("failed to load empty graph: %v", err)
}
if len(graph.Entities) != 0 || len(graph.Relations) != 0 {
t.Errorf("expected empty graph, got %+v", graph)
}
// Create and verify entities
testEntities := []Entity{
{
Name: "Alice",
EntityType: "Person",
Observations: []string{"Likes coffee"},
},
{
Name: "Bob",
EntityType: "Person",
Observations: []string{"Likes tea"},
},
}
createdEntities, err := kb.createEntities(testEntities)
if err != nil {
t.Fatalf("failed to create entities: %v", err)
}
if len(createdEntities) != 2 {
t.Errorf("expected 2 created entities, got %d", len(createdEntities))
}
// Verify entities persist
graph, err = kb.loadGraph()
if err != nil {
t.Fatalf("failed to read graph: %v", err)
}
if len(graph.Entities) != 2 {
t.Errorf("expected 2 entities, got %d", len(graph.Entities))
}
// Create and verify relations
testRelations := []Relation{
{
From: "Alice",
To: "Bob",
RelationType: "friend",
},
}
createdRelations, err := kb.createRelations(testRelations)
if err != nil {
t.Fatalf("failed to create relations: %v", err)
}
if len(createdRelations) != 1 {
t.Errorf("expected 1 created relation, got %d", len(createdRelations))
}
// Add observations to entities
testObservations := []Observation{
{
EntityName: "Alice",
Contents: []string{"Works as developer", "Lives in New York"},
},
}
addedObservations, err := kb.addObservations(testObservations)
if err != nil {
t.Fatalf("failed to add observations: %v", err)
}
if len(addedObservations) != 1 || len(addedObservations[0].Contents) != 2 {
t.Errorf("expected 1 observation with 2 contents, got %+v", addedObservations)
}
// Search nodes by content
searchResult, err := kb.searchNodes("developer")
if err != nil {
t.Fatalf("failed to search nodes: %v", err)
}
if len(searchResult.Entities) != 1 || searchResult.Entities[0].Name != "Alice" {
t.Errorf("expected to find Alice when searching for 'developer', got %+v", searchResult)
}
// Retrieve specific nodes
openResult, err := kb.openNodes([]string{"Bob"})
if err != nil {
t.Fatalf("failed to open nodes: %v", err)
}
if len(openResult.Entities) != 1 || openResult.Entities[0].Name != "Bob" {
t.Errorf("expected to find Bob when opening 'Bob', got %+v", openResult)
}
// Remove specific observations
deleteObs := []Observation{
{
EntityName: "Alice",
Observations: []string{"Works as developer"},
},
}
err = kb.deleteObservations(deleteObs)
if err != nil {
t.Fatalf("failed to delete observations: %v", err)
}
// Confirm observation removal
graph, _ = kb.loadGraph()
aliceIndex := slices.IndexFunc(graph.Entities, func(e Entity) bool {
return e.Name == "Alice"
})
if aliceIndex == -1 {
t.Errorf("entity 'Alice' not found after deleting observation")
} else {
alice := graph.Entities[aliceIndex]
if slices.Contains(alice.Observations, "Works as developer") {
t.Errorf("observation 'Works as developer' should have been deleted")
}
}
// Remove relations
err = kb.deleteRelations(testRelations)
if err != nil {
t.Fatalf("failed to delete relations: %v", err)
}
// Confirm relation removal
graph, _ = kb.loadGraph()
if len(graph.Relations) != 0 {
t.Errorf("expected 0 relations after deletion, got %d", len(graph.Relations))
}
// Remove entities
err = kb.deleteEntities([]string{"Alice"})
if err != nil {
t.Fatalf("failed to delete entities: %v", err)
}
// Confirm entity removal
graph, _ = kb.loadGraph()
if len(graph.Entities) != 1 || graph.Entities[0].Name != "Bob" {
t.Errorf("expected only Bob to remain after deleting Alice, got %+v", graph.Entities)
}
})
}
}
// TestSaveAndLoadGraph ensures data persists correctly across save/load cycles.
func TestSaveAndLoadGraph(t *testing.T) {
for name, newStore := range stores() {
t.Run(name, func(t *testing.T) {
s := newStore(t)
kb := knowledgeBase{s: s}
// Setup test data
testGraph := KnowledgeGraph{
Entities: []Entity{
{
Name: "Charlie",
EntityType: "Person",
Observations: []string{"Likes hiking"},
},
},
Relations: []Relation{
{
From: "Charlie",
To: "Mountains",
RelationType: "enjoys",
},
},
}
// Persist to storage
err := kb.saveGraph(testGraph)
if err != nil {
t.Fatalf("failed to save graph: %v", err)
}
// Reload from storage
loadedGraph, err := kb.loadGraph()
if err != nil {
t.Fatalf("failed to load graph: %v", err)
}
// Verify data integrity
if !reflect.DeepEqual(testGraph, loadedGraph) {
t.Errorf("loaded graph does not match saved graph.\nExpected: %+v\nGot: %+v", testGraph, loadedGraph)
}
// Test malformed data handling
if fs, ok := s.(*fileStore); ok {
err := os.WriteFile(fs.path, []byte("invalid json"), 0600)
if err != nil {
t.Fatalf("failed to write invalid json: %v", err)
}
_, err = kb.loadGraph()
if err == nil {
t.Errorf("expected error when loading invalid JSON, got nil")
}
}
})
}
}
// TestDuplicateEntitiesAndRelations verifies duplicate prevention logic.
func TestDuplicateEntitiesAndRelations(t *testing.T) {
for name, newStore := range stores() {
t.Run(name, func(t *testing.T) {
s := newStore(t)
kb := knowledgeBase{s: s}
// Setup initial state
initialEntities := []Entity{
{
Name: "Dave",
EntityType: "Person",
Observations: []string{"Plays guitar"},
},
}
_, err := kb.createEntities(initialEntities)
if err != nil {
t.Fatalf("failed to create initial entities: %v", err)
}
// Attempt duplicate creation
duplicateEntities := []Entity{
{
Name: "Dave",
EntityType: "Person",
Observations: []string{"Sings well"},
},
{
Name: "Eve",
EntityType: "Person",
Observations: []string{"Plays piano"},
},
}
newEntities, err := kb.createEntities(duplicateEntities)
if err != nil {
t.Fatalf("failed when adding duplicate entities: %v", err)
}
// Verify only new entities created
if len(newEntities) != 1 || newEntities[0].Name != "Eve" {
t.Errorf("expected only 'Eve' to be created, got %+v", newEntities)
}
// Setup initial relation
initialRelation := []Relation{
{
From: "Dave",
To: "Eve",
RelationType: "friend",
},
}
_, err = kb.createRelations(initialRelation)
if err != nil {
t.Fatalf("failed to create initial relation: %v", err)
}
// Test relation deduplication
duplicateRelations := []Relation{
{
From: "Dave",
To: "Eve",
RelationType: "friend",
},
{
From: "Eve",
To: "Dave",
RelationType: "friend",
},
}
newRelations, err := kb.createRelations(duplicateRelations)
if err != nil {
t.Fatalf("failed when adding duplicate relations: %v", err)
}
// Verify only new relations created
if len(newRelations) != 1 || newRelations[0].From != "Eve" || newRelations[0].To != "Dave" {
t.Errorf("expected only 'Eve->Dave' relation to be created, got %+v", newRelations)
}
})
}
}
// TestErrorHandling verifies proper error responses for invalid operations.
func TestErrorHandling(t *testing.T) {
t.Run("FileStoreWriteError", func(t *testing.T) {
// Test file write to invalid path
kb := knowledgeBase{
s: &fileStore{path: filepath.Join("nonexistent", "directory", "file.json")},
}
testEntities := []Entity{
{Name: "TestEntity"},
}
_, err := kb.createEntities(testEntities)
if err == nil {
t.Errorf("expected error when writing to non-existent directory, got nil")
}
})
for name, newStore := range stores() {
t.Run(fmt.Sprintf("AddObservationToNonExistentEntity_%s", name), func(t *testing.T) {
s := newStore(t)
kb := knowledgeBase{s: s}
// Setup valid entity for comparison
_, err := kb.createEntities([]Entity{{Name: "RealEntity"}})
if err != nil {
t.Fatalf("failed to create test entity: %v", err)
}
// Test invalid entity reference
nonExistentObs := []Observation{
{
EntityName: "NonExistentEntity",
Contents: []string{"This shouldn't work"},
},
}
_, err = kb.addObservations(nonExistentObs)
if err == nil {
t.Errorf("expected error when adding observations to non-existent entity, got nil")
}
})
}
}
// TestFileFormatting verifies the JSON storage format structure.
func TestFileFormatting(t *testing.T) {
for name, newStore := range stores() {
t.Run(name, func(t *testing.T) {
s := newStore(t)
kb := knowledgeBase{s: s}
// Setup test entity
testEntities := []Entity{
{
Name: "FileTest",
EntityType: "TestEntity",
Observations: []string{"Test observation"},
},
}
_, err := kb.createEntities(testEntities)
if err != nil {
t.Fatalf("failed to create test entity: %v", err)
}
// Extract raw storage data
data, err := s.Read()
if err != nil {
t.Fatalf("failed to read from store: %v", err)
}
// Validate JSON format
var items []kbItem
err = json.Unmarshal(data, &items)
if err != nil {
t.Fatalf("failed to parse store data JSON: %v", err)
}
// Check data structure
if len(items) != 1 {
t.Fatalf("expected 1 item in memory file, got %d", len(items))
}
item := items[0]
if item.Type != "entity" ||
item.Name != "FileTest" ||
item.EntityType != "TestEntity" ||
len(item.Observations) != 1 ||
item.Observations[0] != "Test observation" {
t.Errorf("store item format incorrect: %+v", item)
}
})
}
}
// TestMCPServerIntegration tests the knowledge base through MCP server layer.
func TestMCPServerIntegration(t *testing.T) {
for name, newStore := range stores() {
t.Run(name, func(t *testing.T) {
s := newStore(t)
kb := knowledgeBase{s: s}
// Create mock server session
ctx := context.Background()
serverSession := &mcp.ServerSession{}
// Test CreateEntities through MCP
createEntitiesParams := &mcp.CallToolParamsFor[CreateEntitiesArgs]{
Arguments: CreateEntitiesArgs{
Entities: []Entity{
{
Name: "TestPerson",
EntityType: "Person",
Observations: []string{"Likes testing"},
},
},
},
}
createResult, err := kb.CreateEntities(ctx, serverSession, createEntitiesParams)
if err != nil {
t.Fatalf("MCP CreateEntities failed: %v", err)
}
if createResult.IsError {
t.Fatalf("MCP CreateEntities returned error: %v", createResult.Content)
}
if len(createResult.StructuredContent.Entities) != 1 {
t.Errorf("expected 1 entity created, got %d", len(createResult.StructuredContent.Entities))
}
// Test ReadGraph through MCP
readParams := &mcp.CallToolParamsFor[struct{}]{}
readResult, err := kb.ReadGraph(ctx, serverSession, readParams)
if err != nil {
t.Fatalf("MCP ReadGraph failed: %v", err)
}
if readResult.IsError {
t.Fatalf("MCP ReadGraph returned error: %v", readResult.Content)
}
if len(readResult.StructuredContent.Entities) != 1 {
t.Errorf("expected 1 entity in graph, got %d", len(readResult.StructuredContent.Entities))
}
// Test CreateRelations through MCP
createRelationsParams := &mcp.CallToolParamsFor[CreateRelationsArgs]{
Arguments: CreateRelationsArgs{
Relations: []Relation{
{
From: "TestPerson",
To: "Testing",
RelationType: "likes",
},
},
},
}
relationsResult, err := kb.CreateRelations(ctx, serverSession, createRelationsParams)
if err != nil {
t.Fatalf("MCP CreateRelations failed: %v", err)
}
if relationsResult.IsError {
t.Fatalf("MCP CreateRelations returned error: %v", relationsResult.Content)
}
if len(relationsResult.StructuredContent.Relations) != 1 {
t.Errorf("expected 1 relation created, got %d", len(relationsResult.StructuredContent.Relations))
}
// Test AddObservations through MCP
addObsParams := &mcp.CallToolParamsFor[AddObservationsArgs]{
Arguments: AddObservationsArgs{
Observations: []Observation{
{
EntityName: "TestPerson",
Contents: []string{"Works remotely", "Drinks coffee"},
},
},
},
}
obsResult, err := kb.AddObservations(ctx, serverSession, addObsParams)
if err != nil {
t.Fatalf("MCP AddObservations failed: %v", err)
}
if obsResult.IsError {
t.Fatalf("MCP AddObservations returned error: %v", obsResult.Content)
}
if len(obsResult.StructuredContent.Observations) != 1 {
t.Errorf("expected 1 observation result, got %d", len(obsResult.StructuredContent.Observations))
}
// Test SearchNodes through MCP
searchParams := &mcp.CallToolParamsFor[SearchNodesArgs]{
Arguments: SearchNodesArgs{
Query: "coffee",
},
}
searchResult, err := kb.SearchNodes(ctx, serverSession, searchParams)
if err != nil {
t.Fatalf("MCP SearchNodes failed: %v", err)
}
if searchResult.IsError {
t.Fatalf("MCP SearchNodes returned error: %v", searchResult.Content)
}
if len(searchResult.StructuredContent.Entities) != 1 {
t.Errorf("expected 1 entity from search, got %d", len(searchResult.StructuredContent.Entities))
}
// Test OpenNodes through MCP
openParams := &mcp.CallToolParamsFor[OpenNodesArgs]{
Arguments: OpenNodesArgs{
Names: []string{"TestPerson"},
},
}
openResult, err := kb.OpenNodes(ctx, serverSession, openParams)
if err != nil {
t.Fatalf("MCP OpenNodes failed: %v", err)
}
if openResult.IsError {
t.Fatalf("MCP OpenNodes returned error: %v", openResult.Content)
}
if len(openResult.StructuredContent.Entities) != 1 {
t.Errorf("expected 1 entity from open, got %d", len(openResult.StructuredContent.Entities))
}
// Test DeleteObservations through MCP
deleteObsParams := &mcp.CallToolParamsFor[DeleteObservationsArgs]{
Arguments: DeleteObservationsArgs{
Deletions: []Observation{
{
EntityName: "TestPerson",
Observations: []string{"Works remotely"},
},
},
},
}
deleteObsResult, err := kb.DeleteObservations(ctx, serverSession, deleteObsParams)
if err != nil {
t.Fatalf("MCP DeleteObservations failed: %v", err)
}
if deleteObsResult.IsError {
t.Fatalf("MCP DeleteObservations returned error: %v", deleteObsResult.Content)
}
// Test DeleteRelations through MCP
deleteRelParams := &mcp.CallToolParamsFor[DeleteRelationsArgs]{
Arguments: DeleteRelationsArgs{
Relations: []Relation{
{
From: "TestPerson",
To: "Testing",
RelationType: "likes",
},
},
},
}
deleteRelResult, err := kb.DeleteRelations(ctx, serverSession, deleteRelParams)
if err != nil {
t.Fatalf("MCP DeleteRelations failed: %v", err)
}
if deleteRelResult.IsError {
t.Fatalf("MCP DeleteRelations returned error: %v", deleteRelResult.Content)
}
// Test DeleteEntities through MCP
deleteEntParams := &mcp.CallToolParamsFor[DeleteEntitiesArgs]{
Arguments: DeleteEntitiesArgs{
EntityNames: []string{"TestPerson"},
},
}
deleteEntResult, err := kb.DeleteEntities(ctx, serverSession, deleteEntParams)
if err != nil {
t.Fatalf("MCP DeleteEntities failed: %v", err)
}
if deleteEntResult.IsError {
t.Fatalf("MCP DeleteEntities returned error: %v", deleteEntResult.Content)
}
// Verify final state
finalRead, err := kb.ReadGraph(ctx, serverSession, readParams)
if err != nil {
t.Fatalf("Final MCP ReadGraph failed: %v", err)
}
if len(finalRead.StructuredContent.Entities) != 0 {
t.Errorf("expected empty graph after deletion, got %d entities", len(finalRead.StructuredContent.Entities))
}
})
}
}
// TestMCPErrorHandling tests error scenarios through MCP layer.
func TestMCPErrorHandling(t *testing.T) {
for name, newStore := range stores() {
t.Run(name, func(t *testing.T) {
s := newStore(t)
kb := knowledgeBase{s: s}
ctx := context.Background()
serverSession := &mcp.ServerSession{}
// Test adding observations to non-existent entity
addObsParams := &mcp.CallToolParamsFor[AddObservationsArgs]{
Arguments: AddObservationsArgs{
Observations: []Observation{
{
EntityName: "NonExistentEntity",
Contents: []string{"This should fail"},
},
},
},
}
_, err := kb.AddObservations(ctx, serverSession, addObsParams)
if err == nil {
t.Errorf("expected MCP AddObservations to return error for non-existent entity")
} else {
// Verify the error message contains expected text
want := "entity with name NonExistentEntity not found"
if !strings.Contains(err.Error(), want) {
t.Errorf("expected error message to contain '%s', got: %v", want, err)
}
}
})
}
}
// TestMCPResponseFormat verifies MCP response format consistency.
func TestMCPResponseFormat(t *testing.T) {
s := &memoryStore{}
kb := knowledgeBase{s: s}
ctx := context.Background()
serverSession := &mcp.ServerSession{}
// Test CreateEntities response format
createParams := &mcp.CallToolParamsFor[CreateEntitiesArgs]{
Arguments: CreateEntitiesArgs{
Entities: []Entity{
{Name: "FormatTest", EntityType: "Test"},
},
},
}
result, err := kb.CreateEntities(ctx, serverSession, createParams)
if err != nil {
t.Fatalf("CreateEntities failed: %v", err)
}
// Verify response has both Content and StructuredContent
if len(result.Content) == 0 {
t.Errorf("expected Content field to be populated")
}
if len(result.StructuredContent.Entities) == 0 {
t.Errorf("expected StructuredContent.Entities to be populated")
}
// Verify Content contains simple success message
if textContent, ok := result.Content[0].(*mcp.TextContent); ok {
expectedMessage := "Entities created successfully"
if textContent.Text != expectedMessage {
t.Errorf("expected Content field to contain '%s', got '%s'", expectedMessage, textContent.Text)
}
} else {
t.Errorf("expected Content[0] to be TextContent")
}
}

View file

@ -0,0 +1,145 @@
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package main
import (
"context"
"flag"
"log"
"net/http"
"os"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
var (
httpAddr = flag.String("http", "", "if set, use streamable HTTP at this address, instead of stdin/stdout")
memoryFilePath = flag.String("memory", "", "if set, persist the knowledge base to this file; otherwise, it will be stored in memory and lost on exit")
)
// HiArgs defines arguments for the greeting tool.
type HiArgs struct {
Name string `json:"name"`
}
// CreateEntitiesArgs defines the create entities tool parameters.
type CreateEntitiesArgs struct {
Entities []Entity `json:"entities" mcp:"entities to create"`
}
// CreateEntitiesResult returns newly created entities.
type CreateEntitiesResult struct {
Entities []Entity `json:"entities"`
}
// CreateRelationsArgs defines the create relations tool parameters.
type CreateRelationsArgs struct {
Relations []Relation `json:"relations" mcp:"relations to create"`
}
// CreateRelationsResult returns newly created relations.
type CreateRelationsResult struct {
Relations []Relation `json:"relations"`
}
// AddObservationsArgs defines the add observations tool parameters.
type AddObservationsArgs struct {
Observations []Observation `json:"observations" mcp:"observations to add"`
}
// AddObservationsResult returns newly added observations.
type AddObservationsResult struct {
Observations []Observation `json:"observations"`
}
// DeleteEntitiesArgs defines the delete entities tool parameters.
type DeleteEntitiesArgs struct {
EntityNames []string `json:"entityNames" mcp:"entities to delete"`
}
// DeleteObservationsArgs defines the delete observations tool parameters.
type DeleteObservationsArgs struct {
Deletions []Observation `json:"deletions" mcp:"obeservations to delete"`
}
// DeleteRelationsArgs defines the delete relations tool parameters.
type DeleteRelationsArgs struct {
Relations []Relation `json:"relations" mcp:"relations to delete"`
}
// SearchNodesArgs defines the search nodes tool parameters.
type SearchNodesArgs struct {
Query string `json:"query" mcp:"query string"`
}
// OpenNodesArgs defines the open nodes tool parameters.
type OpenNodesArgs struct {
Names []string `json:"names" mcp:"names of nodes to open"`
}
func main() {
flag.Parse()
// Initialize storage backend
var kbStore store
kbStore = &memoryStore{}
if *memoryFilePath != "" {
kbStore = &fileStore{path: *memoryFilePath}
}
kb := knowledgeBase{s: kbStore}
// Setup MCP server with knowledge base tools
server := mcp.NewServer(&mcp.Implementation{Name: "memory"}, nil)
mcp.AddTool(server, &mcp.Tool{
Name: "create_entities",
Description: "Create multiple new entities in the knowledge graph",
}, kb.CreateEntities)
mcp.AddTool(server, &mcp.Tool{
Name: "create_relations",
Description: "Create multiple new relations between entities",
}, kb.CreateRelations)
mcp.AddTool(server, &mcp.Tool{
Name: "add_observations",
Description: "Add new observations to existing entities",
}, kb.AddObservations)
mcp.AddTool(server, &mcp.Tool{
Name: "delete_entities",
Description: "Remove entities and their relations",
}, kb.DeleteEntities)
mcp.AddTool(server, &mcp.Tool{
Name: "delete_observations",
Description: "Remove specific observations from entities",
}, kb.DeleteObservations)
mcp.AddTool(server, &mcp.Tool{
Name: "delete_relations",
Description: "Remove specific relations from the graph",
}, kb.DeleteRelations)
mcp.AddTool(server, &mcp.Tool{
Name: "read_graph",
Description: "Read the entire knowledge graph",
}, kb.ReadGraph)
mcp.AddTool(server, &mcp.Tool{
Name: "search_nodes",
Description: "Search for nodes based on query",
}, kb.SearchNodes)
mcp.AddTool(server, &mcp.Tool{
Name: "open_nodes",
Description: "Retrieve specific nodes by name",
}, kb.OpenNodes)
// Start server with appropriate transport
if *httpAddr != "" {
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return server
}, nil)
log.Printf("MCP handler listening at %s", *httpAddr)
http.ListenAndServe(*httpAddr, handler)
} else {
t := mcp.NewLoggingTransport(mcp.NewStdioTransport(), os.Stderr)
if err := server.Run(context.Background(), t); err != nil {
log.Printf("Server failed: %v", err)
}
}
}

View file

@ -0,0 +1,10 @@
module github.com/modelcontextprotocol/go-sdk/examples/rate-limiting
go 1.23.0
toolchain go1.24.4
require (
github.com/modelcontextprotocol/go-sdk v0.1.0
golang.org/x/time v0.12.0
)

View file

@ -0,0 +1,8 @@
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/modelcontextprotocol/go-sdk v0.1.0 h1:ItzbFWYNt4EHcUrScX7P8JPASn1FVYb29G773Xkl+IU=
github.com/modelcontextprotocol/go-sdk v0.1.0/go.mod h1:DcXfbr7yl7e35oMpzHfKw2nUYRjhIGS2uou/6tdsTB0=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=

View file

@ -0,0 +1,96 @@
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package main
import (
"context"
"errors"
"log"
"sync"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
"golang.org/x/time/rate"
)
// GlobalRateLimiterMiddleware creates a middleware that applies a global rate limit.
// Every request attempting to pass through will try to acquire a token.
// If a token cannot be acquired immediately, the request will be rejected.
func GlobalRateLimiterMiddleware[S mcp.Session](limiter *rate.Limiter) mcp.Middleware[S] {
return func(next mcp.MethodHandler[S]) mcp.MethodHandler[S] {
return func(ctx context.Context, session S, method string, params mcp.Params) (mcp.Result, error) {
if !limiter.Allow() {
return nil, errors.New("JSON RPC overloaded")
}
return next(ctx, session, method, params)
}
}
}
// PerMethodRateLimiterMiddleware creates a middleware that applies rate limiting
// on a per-method basis.
// Methods not specified in limiters will not be rate limited by this middleware.
func PerMethodRateLimiterMiddleware[S mcp.Session](limiters map[string]*rate.Limiter) mcp.Middleware[S] {
return func(next mcp.MethodHandler[S]) mcp.MethodHandler[S] {
return func(ctx context.Context, session S, method string, params mcp.Params) (mcp.Result, error) {
if limiter, ok := limiters[method]; ok {
if !limiter.Allow() {
return nil, errors.New("JSON RPC overloaded")
}
}
return next(ctx, session, method, params)
}
}
}
// PerSessionRateLimiterMiddleware creates a middleware that applies rate limiting
// on a per-session basis for receiving requests.
func PerSessionRateLimiterMiddleware[S mcp.Session](limit rate.Limit, burst int) mcp.Middleware[S] {
// A map to store limiters, keyed by the session ID.
var (
sessionLimiters = make(map[string]*rate.Limiter)
mu sync.Mutex
)
return func(next mcp.MethodHandler[S]) mcp.MethodHandler[S] {
return func(ctx context.Context, session S, method string, params mcp.Params) (mcp.Result, error) {
// It's possible that session.ID() may be empty at this point in time
// for some transports (e.g., stdio) or until the MCP initialize handshake
// has completed.
sessionID := session.ID()
if sessionID == "" {
// In this situation, you could apply a single global identifier
// if session ID is empty or bypass the rate limiter.
// In this example, we bypass the rate limiter.
log.Printf("Warning: Session ID is empty for method %q. Skipping per-session rate limiting.", method)
return next(ctx, session, method, params) // Skip limiting if ID is unavailable
}
mu.Lock()
limiter, ok := sessionLimiters[sessionID]
if !ok {
limiter = rate.NewLimiter(limit, burst)
sessionLimiters[sessionID] = limiter
}
mu.Unlock()
if !limiter.Allow() {
return nil, errors.New("JSON RPC overloaded")
}
return next(ctx, session, method, params)
}
}
}
func main() {
server := mcp.NewServer("greeter1", "v0.0.1", nil)
server.AddReceivingMiddleware(GlobalRateLimiterMiddleware[*mcp.ServerSession](rate.NewLimiter(rate.Every(time.Second/5), 10)))
server.AddReceivingMiddleware(PerMethodRateLimiterMiddleware[*mcp.ServerSession](map[string]*rate.Limiter{
"callTool": rate.NewLimiter(rate.Every(time.Second), 5), // once a second with a burst up to 5
"listTools": rate.NewLimiter(rate.Every(time.Minute), 20), // once a minute with a burst up to 20
}))
server.AddReceivingMiddleware(PerSessionRateLimiterMiddleware[*mcp.ServerSession](rate.Every(time.Second/5), 10))
// Run Server logic.
log.Println("MCP Server instance created with Middleware (but not running).")
log.Println("This example demonstrates configuration, not live interaction.")
}

View file

@ -0,0 +1,203 @@
# Sequential Thinking MCP Server
This example shows a Model Context Protocol (MCP) server that enables dynamic and reflective problem-solving through structured thinking processes. It helps break down complex problems into manageable, sequential thought steps with support for revision and branching.
## Features
The server provides three main tools for managing thinking sessions:
### 1. Start Thinking (`start_thinking`)
Begins a new sequential thinking session for a complex problem.
**Parameters:**
- `problem` (string): The problem or question to think about
- `sessionId` (string, optional): Custom session identifier
- `estimatedSteps` (int, optional): Initial estimate of thinking steps needed
### 2. Continue Thinking (`continue_thinking`)
Adds the next thought step, revises previous steps, or creates alternative branches.
**Parameters:**
- `sessionId` (string): The thinking session to continue
- `thought` (string): The current thought or analysis
- `nextNeeded` (bool, optional): Whether another thinking step is needed
- `reviseStep` (int, optional): Step number to revise (1-based)
- `createBranch` (bool, optional): Create an alternative reasoning path
- `estimatedTotal` (int, optional): Update total estimated steps
### 3. Review Thinking (`review_thinking`)
Provides a complete review of the thinking process for a session.
**Parameters:**
- `sessionId` (string): The session to review
## Resources
### Thinking History (`thinking://sessions` or `thinking://{sessionId}`)
Access thinking session data and history in JSON format.
- `thinking://sessions` - List all thinking sessions
- `thinking://{sessionId}` - Get specific session details
## Core Concepts
### Sequential Processing
Problems are broken down into numbered thought steps that build upon each other, maintaining context and allowing for systematic analysis.
### Dynamic Revision
Any previous thought step can be revised and updated, with the system tracking which thoughts have been modified.
### Alternative Branching
Create alternative reasoning paths to explore different approaches to the same problem, allowing for comparative analysis.
### Adaptive Planning
The estimated number of thinking steps can be adjusted dynamically as understanding of the problem evolves.
## Running the Server
### Standard I/O Mode
```bash
go run .
```
### HTTP Mode
```bash
go run . -http :8080
```
## Example Usage
### Starting a Thinking Session
```json
{
"method": "tools/call",
"params": {
"name": "start_thinking",
"arguments": {
"problem": "How should I design a scalable microservices architecture?",
"sessionId": "architecture_design",
"estimatedSteps": 8
}
}
}
```
### Adding Sequential Thoughts
```json
{
"method": "tools/call",
"params": {
"name": "continue_thinking",
"arguments": {
"sessionId": "architecture_design",
"thought": "First, I need to identify the core business domains and their boundaries to determine service decomposition."
}
}
}
```
### Revising a Previous Step
```json
{
"method": "tools/call",
"params": {
"name": "continue_thinking",
"arguments": {
"sessionId": "architecture_design",
"thought": "Actually, before identifying domains, I should analyze the current system's pain points and requirements.",
"reviseStep": 1
}
}
}
```
### Creating an Alternative Branch
```json
{
"method": "tools/call",
"params": {
"name": "continue_thinking",
"arguments": {
"sessionId": "architecture_design",
"thought": "Alternative approach: Start with a monolith-first strategy and extract services gradually.",
"createBranch": true
}
}
}
```
### Completing the Thinking Process
```json
{
"method": "tools/call",
"params": {
"name": "continue_thinking",
"arguments": {
"sessionId": "architecture_design",
"thought": "Based on this analysis, I recommend starting with 3 core services: User Management, Order Processing, and Inventory Management.",
"nextNeeded": false
}
}
}
```
### Reviewing the Complete Process
```json
{
"method": "tools/call",
"params": {
"name": "review_thinking",
"arguments": {
"sessionId": "architecture_design"
}
}
}
```
## Session State Management
Each thinking session maintains:
- **Session metadata**: ID, problem statement, creation time, current status
- **Thought sequence**: Ordered list of thoughts with timestamps and revision history
- **Progress tracking**: Current step and estimated total steps
- **Branch relationships**: Links to alternative reasoning paths
- **Status management**: Active, completed, or paused sessions
## Use Cases
**Ideal for:**
- Complex problem analysis requiring step-by-step breakdown
- Design decisions needing systematic evaluation
- Scenarios where initial scope is unclear and may evolve
- Problems requiring alternative approach exploration
- Situations needing detailed reasoning documentation
**Examples:**
- Software architecture design
- Research methodology planning
- Strategic business decisions
- Technical troubleshooting
- Creative problem solving
- Academic research planning

View file

@ -0,0 +1,544 @@
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package main
import (
"context"
"crypto/rand"
"encoding/json"
"flag"
"fmt"
"log"
"maps"
"net/http"
"net/url"
"os"
"slices"
"strings"
"sync"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
var httpAddr = flag.String("http", "", "if set, use streamable HTTP at this address, instead of stdin/stdout")
// A Thought is a single step in the thinking process.
type Thought struct {
// Index of the thought within the session (1-based).
Index int `json:"index"`
// Content of the thought.
Content string `json:"content"`
// Time the thought was created.
Created time.Time `json:"created"`
// Whether the thought has been revised.
Revised bool `json:"revised"`
// Index of parent thought, or nil if this is a root for branching.
ParentIndex *int `json:"parentIndex,omitempty"`
}
// A ThinkingSession is an active thinking session.
type ThinkingSession struct {
// Globally unique ID of the session.
ID string `json:"id"`
// Problem to solve.
Problem string `json:"problem"`
// Thoughts in the session.
Thoughts []*Thought `json:"thoughts"`
// Current thought index.
CurrentThought int `json:"currentThought"`
// Estimated total number of thoughts.
EstimatedTotal int `json:"estimatedTotal"`
// Status of the session.
Status string `json:"status"` // "active", "completed", "paused"
// Time the session was created.
Created time.Time `json:"created"`
// Time the session was last active.
LastActivity time.Time `json:"lastActivity"`
// Branches in the session. Alternative thought paths.
Branches []string `json:"branches,omitempty"`
// Version for optimistic concurrency control.
Version int `json:"version"`
}
// clone returns a deep copy of the ThinkingSession.
func (s *ThinkingSession) clone() *ThinkingSession {
sessionCopy := *s
sessionCopy.Thoughts = deepCopyThoughts(s.Thoughts)
sessionCopy.Branches = slices.Clone(s.Branches)
return &sessionCopy
}
// A SessionStore is a global session store (in a real implementation, this might be a database).
//
// Locking Strategy:
// The SessionStore uses a RWMutex to protect the sessions map from concurrent access.
// All ThinkingSession modifications happen on deep copies, never on shared instances.
// This means:
// - Read locks protect map access.
// - Write locks protect map modifications (adding/removing/replacing sessions)
// - Session field modifications always happen on local copies via CompareAndSwap
// - No shared ThinkingSession state is ever modified directly
type SessionStore struct {
mu sync.RWMutex
sessions map[string]*ThinkingSession // key is session ID
}
// NewSessionStore creates a new session store for managing thinking sessions.
func NewSessionStore() *SessionStore {
return &SessionStore{
sessions: make(map[string]*ThinkingSession),
}
}
// Session retrieves a thinking session by ID, returning the session and whether it exists.
func (s *SessionStore) Session(id string) (*ThinkingSession, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
session, exists := s.sessions[id]
return session, exists
}
// SetSession stores or updates a thinking session in the store.
func (s *SessionStore) SetSession(session *ThinkingSession) {
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[session.ID] = session
}
// CompareAndSwap atomically updates a session if the version matches.
// Returns true if the update succeeded, false if there was a version mismatch.
//
// This method implements optimistic concurrency control:
// 1. Read lock to safely access the map and copy the session
// 2. Deep copy the session (all modifications happen on this copy)
// 3. Release read lock and apply updates to the copy
// 4. Write lock to check version and atomically update if unchanged
//
// The read lock in step 1 is necessary to prevent map access races,
// not to protect ThinkingSession fields (which are never modified in-place).
func (s *SessionStore) CompareAndSwap(sessionID string, updateFunc func(*ThinkingSession) (*ThinkingSession, error)) error {
for {
// Get current session
s.mu.RLock()
current, exists := s.sessions[sessionID]
if !exists {
s.mu.RUnlock()
return fmt.Errorf("session %s not found", sessionID)
}
// Create a deep copy
sessionCopy := current.clone()
oldVersion := current.Version
s.mu.RUnlock()
// Apply the update
updated, err := updateFunc(sessionCopy)
if err != nil {
return err
}
// Try to save
s.mu.Lock()
current, exists = s.sessions[sessionID]
if !exists {
s.mu.Unlock()
return fmt.Errorf("session %s not found", sessionID)
}
if current.Version != oldVersion {
// Version mismatch, retry
s.mu.Unlock()
continue
}
updated.Version = oldVersion + 1
s.sessions[sessionID] = updated
s.mu.Unlock()
return nil
}
}
// Sessions returns all thinking sessions in the store.
func (s *SessionStore) Sessions() []*ThinkingSession {
s.mu.RLock()
defer s.mu.RUnlock()
return slices.Collect(maps.Values(s.sessions))
}
// SessionsSnapshot returns a deep copy of all sessions for safe concurrent access.
func (s *SessionStore) SessionsSnapshot() []*ThinkingSession {
s.mu.RLock()
defer s.mu.RUnlock()
var sessions []*ThinkingSession
for _, session := range s.sessions {
sessions = append(sessions, session.clone())
}
return sessions
}
// SessionSnapshot returns a deep copy of a session for safe concurrent access.
// The second return value reports whether a session with the given id exists.
func (s *SessionStore) SessionSnapshot(id string) (*ThinkingSession, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
session, exists := s.sessions[id]
if !exists {
return nil, false
}
return session.clone(), true
}
var store = NewSessionStore()
// StartThinkingArgs are the arguments for starting a new thinking session.
type StartThinkingArgs struct {
Problem string `json:"problem"`
SessionID string `json:"sessionId,omitempty"`
EstimatedSteps int `json:"estimatedSteps,omitempty"`
}
// ContinueThinkingArgs are the arguments for continuing a thinking session.
type ContinueThinkingArgs struct {
SessionID string `json:"sessionId"`
Thought string `json:"thought"`
NextNeeded *bool `json:"nextNeeded,omitempty"`
ReviseStep *int `json:"reviseStep,omitempty"`
CreateBranch bool `json:"createBranch,omitempty"`
EstimatedTotal int `json:"estimatedTotal,omitempty"`
}
// ReviewThinkingArgs are the arguments for reviewing a thinking session.
type ReviewThinkingArgs struct {
SessionID string `json:"sessionId"`
}
// ThinkingHistoryArgs are the arguments for retrieving thinking history.
type ThinkingHistoryArgs struct {
SessionID string `json:"sessionId"`
}
// deepCopyThoughts creates a deep copy of a slice of thoughts.
func deepCopyThoughts(thoughts []*Thought) []*Thought {
thoughtsCopy := make([]*Thought, len(thoughts))
for i, t := range thoughts {
t2 := *t
thoughtsCopy[i] = &t2
}
return thoughtsCopy
}
// StartThinking begins a new sequential thinking session for a complex problem.
func StartThinking(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolParamsFor[StartThinkingArgs]) (*mcp.CallToolResultFor[any], error) {
args := params.Arguments
sessionID := args.SessionID
if sessionID == "" {
sessionID = randText()
}
estimatedSteps := args.EstimatedSteps
if estimatedSteps == 0 {
estimatedSteps = 5 // Default estimate
}
session := &ThinkingSession{
ID: sessionID,
Problem: args.Problem,
EstimatedTotal: estimatedSteps,
Status: "active",
Created: time.Now(),
LastActivity: time.Now(),
}
store.SetSession(session)
return &mcp.CallToolResultFor[any]{
Content: []mcp.Content{
&mcp.TextContent{
Text: fmt.Sprintf("Started thinking session '%s' for problem: %s\nEstimated steps: %d\nReady for your first thought.",
sessionID, args.Problem, estimatedSteps),
},
},
}, nil
}
// ContinueThinking adds the next thought step, revises a previous step, or creates a branch in the thinking process.
func ContinueThinking(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolParamsFor[ContinueThinkingArgs]) (*mcp.CallToolResultFor[any], error) {
args := params.Arguments
// Handle revision of existing thought
if args.ReviseStep != nil {
err := store.CompareAndSwap(args.SessionID, func(session *ThinkingSession) (*ThinkingSession, error) {
stepIndex := *args.ReviseStep - 1
if stepIndex < 0 || stepIndex >= len(session.Thoughts) {
return nil, fmt.Errorf("invalid step number: %d", *args.ReviseStep)
}
session.Thoughts[stepIndex].Content = args.Thought
session.Thoughts[stepIndex].Revised = true
session.LastActivity = time.Now()
return session, nil
})
if err != nil {
return nil, err
}
return &mcp.CallToolResultFor[any]{
Content: []mcp.Content{
&mcp.TextContent{
Text: fmt.Sprintf("Revised step %d in session '%s':\n%s",
*args.ReviseStep, args.SessionID, args.Thought),
},
},
}, nil
}
// Handle branching
if args.CreateBranch {
var branchID string
var branchSession *ThinkingSession
err := store.CompareAndSwap(args.SessionID, func(session *ThinkingSession) (*ThinkingSession, error) {
branchID = fmt.Sprintf("%s_branch_%d", args.SessionID, len(session.Branches)+1)
session.Branches = append(session.Branches, branchID)
session.LastActivity = time.Now()
// Create a new session for the branch (deep copy thoughts)
thoughtsCopy := deepCopyThoughts(session.Thoughts)
branchSession = &ThinkingSession{
ID: branchID,
Problem: session.Problem + " (Alternative branch)",
Thoughts: thoughtsCopy,
CurrentThought: len(session.Thoughts),
EstimatedTotal: session.EstimatedTotal,
Status: "active",
Created: time.Now(),
LastActivity: time.Now(),
}
return session, nil
})
if err != nil {
return nil, err
}
// Save the branch session
store.SetSession(branchSession)
return &mcp.CallToolResultFor[any]{
Content: []mcp.Content{
&mcp.TextContent{
Text: fmt.Sprintf("Created branch '%s' from session '%s'. You can now continue thinking in either session.",
branchID, args.SessionID),
},
},
}, nil
}
// Add new thought
var thoughtID int
var progress string
var statusMsg string
err := store.CompareAndSwap(args.SessionID, func(session *ThinkingSession) (*ThinkingSession, error) {
thoughtID = len(session.Thoughts) + 1
thought := &Thought{
Index: thoughtID,
Content: args.Thought,
Created: time.Now(),
Revised: false,
}
session.Thoughts = append(session.Thoughts, thought)
session.CurrentThought = thoughtID
session.LastActivity = time.Now()
// Update estimated total if provided
if args.EstimatedTotal > 0 {
session.EstimatedTotal = args.EstimatedTotal
}
// Check if thinking is complete
if args.NextNeeded != nil && !*args.NextNeeded {
session.Status = "completed"
}
// Prepare response strings
progress = fmt.Sprintf("Step %d", thoughtID)
if session.EstimatedTotal > 0 {
progress += fmt.Sprintf(" of ~%d", session.EstimatedTotal)
}
if session.Status == "completed" {
statusMsg = "\n✓ Thinking process completed!"
} else {
statusMsg = "\nReady for next thought..."
}
return session, nil
})
if err != nil {
return nil, err
}
return &mcp.CallToolResultFor[any]{
Content: []mcp.Content{
&mcp.TextContent{
Text: fmt.Sprintf("Session '%s' - %s:\n%s%s",
args.SessionID, progress, args.Thought, statusMsg),
},
},
}, nil
}
// ReviewThinking provides a complete review of the thinking process for a session.
func ReviewThinking(ctx context.Context, ss *mcp.ServerSession, params *mcp.CallToolParamsFor[ReviewThinkingArgs]) (*mcp.CallToolResultFor[any], error) {
args := params.Arguments
// Get a snapshot of the session to avoid race conditions
sessionSnapshot, exists := store.SessionSnapshot(args.SessionID)
if !exists {
return nil, fmt.Errorf("session %s not found", args.SessionID)
}
var review strings.Builder
fmt.Fprintf(&review, "=== Thinking Review: %s ===\n", sessionSnapshot.ID)
fmt.Fprintf(&review, "Problem: %s\n", sessionSnapshot.Problem)
fmt.Fprintf(&review, "Status: %s\n", sessionSnapshot.Status)
fmt.Fprintf(&review, "Steps: %d of ~%d\n", len(sessionSnapshot.Thoughts), sessionSnapshot.EstimatedTotal)
if len(sessionSnapshot.Branches) > 0 {
fmt.Fprintf(&review, "Branches: %s\n", strings.Join(sessionSnapshot.Branches, ", "))
}
fmt.Fprintf(&review, "\n--- Thought Sequence ---\n")
for i, thought := range sessionSnapshot.Thoughts {
status := ""
if thought.Revised {
status = " (revised)"
}
fmt.Fprintf(&review, "%d. %s%s\n", i+1, thought.Content, status)
}
return &mcp.CallToolResultFor[any]{
Content: []mcp.Content{
&mcp.TextContent{
Text: review.String(),
},
},
}, nil
}
// ThinkingHistory handles resource requests for thinking session data and history.
func ThinkingHistory(ctx context.Context, ss *mcp.ServerSession, params *mcp.ReadResourceParams) (*mcp.ReadResourceResult, error) {
// Extract session ID from URI (e.g., "thinking://session_123")
u, err := url.Parse(params.URI)
if err != nil {
return nil, fmt.Errorf("invalid thinking resource URI: %s", params.URI)
}
if u.Scheme != "thinking" {
return nil, fmt.Errorf("invalid thinking resource URI scheme: %s", u.Scheme)
}
sessionID := u.Host
if sessionID == "sessions" {
// List all sessions - use snapshot for thread safety
sessions := store.SessionsSnapshot()
data, err := json.MarshalIndent(sessions, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal sessions: %w", err)
}
return &mcp.ReadResourceResult{
Contents: []*mcp.ResourceContents{
{
URI: params.URI,
MIMEType: "application/json",
Text: string(data),
},
},
}, nil
}
// Get specific session - use snapshot for thread safety
session, exists := store.SessionSnapshot(sessionID)
if !exists {
return nil, fmt.Errorf("session %s not found", sessionID)
}
data, err := json.MarshalIndent(session, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal session: %w", err)
}
return &mcp.ReadResourceResult{
Contents: []*mcp.ResourceContents{
{
URI: params.URI,
MIMEType: "application/json",
Text: string(data),
},
},
}, nil
}
// Copied from crypto/rand.
// TODO: once 1.24 is assured, just use crypto/rand.
const base32alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
func randText() string {
// ⌈log₃₂ 2¹²⁸⌉ = 26 chars
src := make([]byte, 26)
rand.Read(src)
for i := range src {
src[i] = base32alphabet[src[i]%32]
}
return string(src)
}
func main() {
flag.Parse()
server := mcp.NewServer(&mcp.Implementation{Name: "sequential-thinking"}, nil)
mcp.AddTool(server, &mcp.Tool{
Name: "start_thinking",
Description: "Begin a new sequential thinking session for a complex problem",
}, StartThinking)
mcp.AddTool(server, &mcp.Tool{
Name: "continue_thinking",
Description: "Add the next thought step, revise a previous step, or create a branch",
}, ContinueThinking)
mcp.AddTool(server, &mcp.Tool{
Name: "review_thinking",
Description: "Review the complete thinking process for a session",
}, ReviewThinking)
server.AddResource(&mcp.Resource{
Name: "thinking_sessions",
Description: "Access thinking session data and history",
URI: "thinking://sessions",
MIMEType: "application/json",
}, ThinkingHistory)
if *httpAddr != "" {
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return server
}, nil)
log.Printf("Sequential Thinking MCP server listening at %s", *httpAddr)
if err := http.ListenAndServe(*httpAddr, handler); err != nil {
log.Fatal(err)
}
} else {
t := mcp.NewLoggingTransport(mcp.NewStdioTransport(), os.Stderr)
if err := server.Run(context.Background(), t); err != nil {
log.Printf("Server failed: %v", err)
}
}
}

View file

@ -0,0 +1,548 @@
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package main
import (
"context"
"encoding/json"
"strings"
"testing"
"time"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
func TestStartThinking(t *testing.T) {
// Reset store for clean test
store = NewSessionStore()
ctx := context.Background()
args := StartThinkingArgs{
Problem: "How to implement a binary search algorithm",
SessionID: "test_session",
EstimatedSteps: 5,
}
params := &mcp.CallToolParamsFor[StartThinkingArgs]{
Name: "start_thinking",
Arguments: args,
}
result, err := StartThinking(ctx, nil, params)
if err != nil {
t.Fatalf("StartThinking() error = %v", err)
}
if len(result.Content) == 0 {
t.Fatal("No content in result")
}
textContent, ok := result.Content[0].(*mcp.TextContent)
if !ok {
t.Fatal("Expected TextContent")
}
if !strings.Contains(textContent.Text, "test_session") {
t.Error("Result should contain session ID")
}
if !strings.Contains(textContent.Text, "How to implement a binary search algorithm") {
t.Error("Result should contain the problem statement")
}
// Verify session was stored
session, exists := store.Session("test_session")
if !exists {
t.Fatal("Session was not stored")
}
if session.Problem != args.Problem {
t.Errorf("Expected problem %s, got %s", args.Problem, session.Problem)
}
if session.EstimatedTotal != 5 {
t.Errorf("Expected estimated total 5, got %d", session.EstimatedTotal)
}
if session.Status != "active" {
t.Errorf("Expected status 'active', got %s", session.Status)
}
}
func TestContinueThinking(t *testing.T) {
// Reset store and create initial session
store = NewSessionStore()
// First start a thinking session
ctx := context.Background()
startArgs := StartThinkingArgs{
Problem: "Test problem",
SessionID: "test_continue",
EstimatedSteps: 3,
}
startParams := &mcp.CallToolParamsFor[StartThinkingArgs]{
Name: "start_thinking",
Arguments: startArgs,
}
_, err := StartThinking(ctx, nil, startParams)
if err != nil {
t.Fatalf("StartThinking() error = %v", err)
}
// Now continue thinking
continueArgs := ContinueThinkingArgs{
SessionID: "test_continue",
Thought: "First thought: I need to understand the problem",
}
continueParams := &mcp.CallToolParamsFor[ContinueThinkingArgs]{
Name: "continue_thinking",
Arguments: continueArgs,
}
result, err := ContinueThinking(ctx, nil, continueParams)
if err != nil {
t.Fatalf("ContinueThinking() error = %v", err)
}
// Verify result
if len(result.Content) == 0 {
t.Fatal("No content in result")
}
textContent, ok := result.Content[0].(*mcp.TextContent)
if !ok {
t.Fatal("Expected TextContent")
}
if !strings.Contains(textContent.Text, "Step 1") {
t.Error("Result should contain step number")
}
// Verify session was updated
session, exists := store.Session("test_continue")
if !exists {
t.Fatal("Session not found")
}
if len(session.Thoughts) != 1 {
t.Errorf("Expected 1 thought, got %d", len(session.Thoughts))
}
if session.Thoughts[0].Content != continueArgs.Thought {
t.Errorf("Expected thought content %s, got %s", continueArgs.Thought, session.Thoughts[0].Content)
}
if session.CurrentThought != 1 {
t.Errorf("Expected current thought 1, got %d", session.CurrentThought)
}
}
func TestContinueThinkingWithCompletion(t *testing.T) {
// Reset store and create initial session
store = NewSessionStore()
ctx := context.Background()
startArgs := StartThinkingArgs{
Problem: "Simple test",
SessionID: "test_completion",
}
startParams := &mcp.CallToolParamsFor[StartThinkingArgs]{
Name: "start_thinking",
Arguments: startArgs,
}
_, err := StartThinking(ctx, nil, startParams)
if err != nil {
t.Fatalf("StartThinking() error = %v", err)
}
// Continue with completion flag
nextNeeded := false
continueArgs := ContinueThinkingArgs{
SessionID: "test_completion",
Thought: "Final thought",
NextNeeded: &nextNeeded,
}
continueParams := &mcp.CallToolParamsFor[ContinueThinkingArgs]{
Name: "continue_thinking",
Arguments: continueArgs,
}
result, err := ContinueThinking(ctx, nil, continueParams)
if err != nil {
t.Fatalf("ContinueThinking() error = %v", err)
}
// Check completion message
textContent, ok := result.Content[0].(*mcp.TextContent)
if !ok {
t.Fatal("Expected TextContent")
}
if !strings.Contains(textContent.Text, "completed") {
t.Error("Result should indicate completion")
}
// Verify session status
session, exists := store.Session("test_completion")
if !exists {
t.Fatal("Session not found")
}
if session.Status != "completed" {
t.Errorf("Expected status 'completed', got %s", session.Status)
}
}
func TestContinueThinkingRevision(t *testing.T) {
// Setup session with existing thoughts
store = NewSessionStore()
session := &ThinkingSession{
ID: "test_revision",
Problem: "Test problem",
Thoughts: []*Thought{
{Index: 1, Content: "Original thought", Created: time.Now()},
{Index: 2, Content: "Second thought", Created: time.Now()},
},
CurrentThought: 2,
EstimatedTotal: 3,
Status: "active",
Created: time.Now(),
LastActivity: time.Now(),
}
store.SetSession(session)
ctx := context.Background()
reviseStep := 1
continueArgs := ContinueThinkingArgs{
SessionID: "test_revision",
Thought: "Revised first thought",
ReviseStep: &reviseStep,
}
continueParams := &mcp.CallToolParamsFor[ContinueThinkingArgs]{
Name: "continue_thinking",
Arguments: continueArgs,
}
result, err := ContinueThinking(ctx, nil, continueParams)
if err != nil {
t.Fatalf("ContinueThinking() error = %v", err)
}
// Verify revision message
textContent, ok := result.Content[0].(*mcp.TextContent)
if !ok {
t.Fatal("Expected TextContent")
}
if !strings.Contains(textContent.Text, "Revised step 1") {
t.Error("Result should indicate revision")
}
// Verify thought was revised
updatedSession, _ := store.Session("test_revision")
if updatedSession.Thoughts[0].Content != "Revised first thought" {
t.Error("First thought should be revised")
}
if !updatedSession.Thoughts[0].Revised {
t.Error("First thought should be marked as revised")
}
}
func TestContinueThinkingBranching(t *testing.T) {
// Setup session with existing thoughts
store = NewSessionStore()
session := &ThinkingSession{
ID: "test_branch",
Problem: "Test problem",
Thoughts: []*Thought{
{Index: 1, Content: "First thought", Created: time.Now()},
},
CurrentThought: 1,
EstimatedTotal: 3,
Status: "active",
Created: time.Now(),
LastActivity: time.Now(),
Branches: []string{},
}
store.SetSession(session)
ctx := context.Background()
continueArgs := ContinueThinkingArgs{
SessionID: "test_branch",
Thought: "Alternative approach",
CreateBranch: true,
}
continueParams := &mcp.CallToolParamsFor[ContinueThinkingArgs]{
Name: "continue_thinking",
Arguments: continueArgs,
}
result, err := ContinueThinking(ctx, nil, continueParams)
if err != nil {
t.Fatalf("ContinueThinking() error = %v", err)
}
// Verify branch creation message
textContent, ok := result.Content[0].(*mcp.TextContent)
if !ok {
t.Fatal("Expected TextContent")
}
if !strings.Contains(textContent.Text, "Created branch") {
t.Error("Result should indicate branch creation")
}
// Verify branch was created
updatedSession, _ := store.Session("test_branch")
if len(updatedSession.Branches) != 1 {
t.Errorf("Expected 1 branch, got %d", len(updatedSession.Branches))
}
branchID := updatedSession.Branches[0]
if !strings.Contains(branchID, "test_branch_branch_") {
t.Error("Branch ID should contain parent session ID")
}
// Verify branch session exists
branchSession, exists := store.Session(branchID)
if !exists {
t.Fatal("Branch session should exist")
}
if len(branchSession.Thoughts) != 1 {
t.Error("Branch should inherit parent thoughts")
}
}
func TestReviewThinking(t *testing.T) {
// Setup session with thoughts
store = NewSessionStore()
session := &ThinkingSession{
ID: "test_review",
Problem: "Complex problem",
Thoughts: []*Thought{
{Index: 1, Content: "First thought", Created: time.Now(), Revised: false},
{Index: 2, Content: "Second thought", Created: time.Now(), Revised: true},
{Index: 3, Content: "Final thought", Created: time.Now(), Revised: false},
},
CurrentThought: 3,
EstimatedTotal: 3,
Status: "completed",
Created: time.Now(),
LastActivity: time.Now(),
Branches: []string{"test_review_branch_1"},
}
store.SetSession(session)
ctx := context.Background()
reviewArgs := ReviewThinkingArgs{
SessionID: "test_review",
}
reviewParams := &mcp.CallToolParamsFor[ReviewThinkingArgs]{
Name: "review_thinking",
Arguments: reviewArgs,
}
result, err := ReviewThinking(ctx, nil, reviewParams)
if err != nil {
t.Fatalf("ReviewThinking() error = %v", err)
}
// Verify review content
textContent, ok := result.Content[0].(*mcp.TextContent)
if !ok {
t.Fatal("Expected TextContent")
}
reviewText := textContent.Text
if !strings.Contains(reviewText, "test_review") {
t.Error("Review should contain session ID")
}
if !strings.Contains(reviewText, "Complex problem") {
t.Error("Review should contain problem")
}
if !strings.Contains(reviewText, "completed") {
t.Error("Review should contain status")
}
if !strings.Contains(reviewText, "Steps: 3 of ~3") {
t.Error("Review should contain step count")
}
if !strings.Contains(reviewText, "First thought") {
t.Error("Review should contain first thought")
}
if !strings.Contains(reviewText, "(revised)") {
t.Error("Review should indicate revised thoughts")
}
if !strings.Contains(reviewText, "test_review_branch_1") {
t.Error("Review should list branches")
}
}
func TestThinkingHistory(t *testing.T) {
// Setup test sessions
store = NewSessionStore()
session1 := &ThinkingSession{
ID: "session1",
Problem: "Problem 1",
Thoughts: []*Thought{{Index: 1, Content: "Thought 1", Created: time.Now()}},
CurrentThought: 1,
EstimatedTotal: 2,
Status: "active",
Created: time.Now(),
LastActivity: time.Now(),
}
session2 := &ThinkingSession{
ID: "session2",
Problem: "Problem 2",
Thoughts: []*Thought{{Index: 1, Content: "Thought 1", Created: time.Now()}},
CurrentThought: 1,
EstimatedTotal: 3,
Status: "completed",
Created: time.Now(),
LastActivity: time.Now(),
}
store.SetSession(session1)
store.SetSession(session2)
ctx := context.Background()
// Test listing all sessions
listParams := &mcp.ReadResourceParams{
URI: "thinking://sessions",
}
result, err := ThinkingHistory(ctx, nil, listParams)
if err != nil {
t.Fatalf("ThinkingHistory() error = %v", err)
}
if len(result.Contents) != 1 {
t.Fatal("Expected 1 content item")
}
content := result.Contents[0]
if content.MIMEType != "application/json" {
t.Error("Expected JSON MIME type")
}
// Parse and verify sessions list
var sessions []*ThinkingSession
err = json.Unmarshal([]byte(content.Text), &sessions)
if err != nil {
t.Fatalf("Failed to parse sessions JSON: %v", err)
}
if len(sessions) != 2 {
t.Errorf("Expected 2 sessions, got %d", len(sessions))
}
// Test getting specific session
sessionParams := &mcp.ReadResourceParams{
URI: "thinking://session1",
}
result, err = ThinkingHistory(ctx, nil, sessionParams)
if err != nil {
t.Fatalf("ThinkingHistory() error = %v", err)
}
var retrievedSession ThinkingSession
err = json.Unmarshal([]byte(result.Contents[0].Text), &retrievedSession)
if err != nil {
t.Fatalf("Failed to parse session JSON: %v", err)
}
if retrievedSession.ID != "session1" {
t.Errorf("Expected session ID 'session1', got %s", retrievedSession.ID)
}
if retrievedSession.Problem != "Problem 1" {
t.Errorf("Expected problem 'Problem 1', got %s", retrievedSession.Problem)
}
}
func TestInvalidOperations(t *testing.T) {
store = NewSessionStore()
ctx := context.Background()
// Test continue thinking with non-existent session
continueArgs := ContinueThinkingArgs{
SessionID: "nonexistent",
Thought: "Some thought",
}
continueParams := &mcp.CallToolParamsFor[ContinueThinkingArgs]{
Name: "continue_thinking",
Arguments: continueArgs,
}
_, err := ContinueThinking(ctx, nil, continueParams)
if err == nil {
t.Error("Expected error for non-existent session")
}
// Test review with non-existent session
reviewArgs := ReviewThinkingArgs{
SessionID: "nonexistent",
}
reviewParams := &mcp.CallToolParamsFor[ReviewThinkingArgs]{
Name: "review_thinking",
Arguments: reviewArgs,
}
_, err = ReviewThinking(ctx, nil, reviewParams)
if err == nil {
t.Error("Expected error for non-existent session in review")
}
// Test invalid revision step
session := &ThinkingSession{
ID: "test_invalid",
Problem: "Test",
Thoughts: []*Thought{{Index: 1, Content: "Thought", Created: time.Now()}},
CurrentThought: 1,
EstimatedTotal: 2,
Status: "active",
Created: time.Now(),
LastActivity: time.Now(),
}
store.SetSession(session)
reviseStep := 5 // Invalid step number
invalidReviseArgs := ContinueThinkingArgs{
SessionID: "test_invalid",
Thought: "Revised",
ReviseStep: &reviseStep,
}
invalidReviseParams := &mcp.CallToolParamsFor[ContinueThinkingArgs]{
Name: "continue_thinking",
Arguments: invalidReviseArgs,
}
_, err = ContinueThinking(ctx, nil, invalidReviseParams)
if err == nil {
t.Error("Expected error for invalid revision step")
}
}

View file

@ -0,0 +1,70 @@
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
// Use of this source code is governed by an MIT-style
// license that can be found in the LICENSE file.
package main
import (
"context"
"flag"
"fmt"
"log"
"net/http"
"os"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
var (
host = flag.String("host", "localhost", "host to listen on")
port = flag.String("port", "8080", "port to listen on")
)
type SayHiParams struct {
Name string `json:"name"`
}
func SayHi(ctx context.Context, cc *mcp.ServerSession, params *mcp.CallToolParamsFor[SayHiParams]) (*mcp.CallToolResultFor[any], error) {
return &mcp.CallToolResultFor[any]{
Content: []mcp.Content{
&mcp.TextContent{Text: "Hi " + params.Arguments.Name},
},
}, nil
}
func main() {
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [options]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "This program runs MCP servers over SSE HTTP.\n\n")
fmt.Fprintf(os.Stderr, "Options:\n")
flag.PrintDefaults()
fmt.Fprintf(os.Stderr, "\nEndpoints:\n")
fmt.Fprintf(os.Stderr, " /greeter1 - Greeter 1 service\n")
fmt.Fprintf(os.Stderr, " /greeter2 - Greeter 2 service\n")
os.Exit(1)
}
flag.Parse()
addr := fmt.Sprintf("%s:%s", *host, *port)
server1 := mcp.NewServer(&mcp.Implementation{Name: "greeter1"}, nil)
mcp.AddTool(server1, &mcp.Tool{Name: "greet1", Description: "say hi"}, SayHi)
server2 := mcp.NewServer(&mcp.Implementation{Name: "greeter2"}, nil)
mcp.AddTool(server2, &mcp.Tool{Name: "greet2", Description: "say hello"}, SayHi)
log.Printf("MCP servers serving at %s", addr)
handler := mcp.NewSSEHandler(func(request *http.Request) *mcp.Server {
url := request.URL.Path
log.Printf("Handling request for URL %s\n", url)
switch url {
case "/greeter1":
return server1
case "/greeter2":
return server2
default:
return nil
}
})
log.Fatal(http.ListenAndServe(addr, handler))
}