Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions .beans/beans-kp3h--wrap-beans-in-an-mcp-server.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
---
# beans-kp3h
title: Wrap beans in an MCP server
status: draft
status: completed
type: feature
priority: normal
tags:
- idea
created_at: 2025-12-13T12:00:03Z
updated_at: 2025-12-13T12:11:07Z
updated_at: 2025-12-13T12:46:50Z
---

Expose beans functionality through a Model Context Protocol (MCP) server, allowing AI assistants and other MCP-compatible clients to interact with beans programmatically.
Expand Down Expand Up @@ -43,8 +44,8 @@ The `beans web` feature (beans-lbjp) plans to expose GraphQL over HTTP. These co

## Implementation

- [ ] Choose Go MCP library (or implement minimal stdio protocol)
- [ ] Create `beans mcp` command that runs stdio MCP server
- [ ] Single `beans_graphql` tool wrapping the existing GraphQL engine
- [ ] Tool description generated from schema + usage docs
- [x] Choose Go MCP library (or implement minimal stdio protocol)
- [x] Create `beans mcp` command that runs stdio MCP server
- [x] Single `beans_graphql` tool wrapping the existing GraphQL engine
- [x] Tool description generated from schema + usage docs
- [ ] Test with Claude Code MCP integration
23 changes: 1 addition & 22 deletions .claude/settings.json
Original file line number Diff line number Diff line change
@@ -1,24 +1,3 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "beans prime"
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "beans prime"
}
]
}
]
}
"hooks": {}
}
22 changes: 17 additions & 5 deletions cmd/graphql.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/tidwall/pretty"
"github.com/vektah/gqlparser/v2/formatter"
"github.com/vektah/gqlparser/v2/gqlerror"
"github.com/hmans/beans/internal/beancore"
"github.com/hmans/beans/internal/graph"
)

Expand Down Expand Up @@ -95,7 +96,7 @@ Examples:
}

// Execute the query
result, err := executeQuery(query, variables, queryOperation)
result, err := ExecuteQuery(query, variables, queryOperation)
if err != nil {
return err
}
Expand Down Expand Up @@ -133,14 +134,25 @@ func readFromStdin() (string, error) {
return strings.TrimSpace(string(data)), nil
}

// executeQuery runs a GraphQL query against the beans core.
// ExecuteQuery runs a GraphQL query against the beans core.
// On success, it returns just the data portion of the response.
// On error, it returns an error so the CLI can handle it appropriately.
func executeQuery(query string, variables map[string]any, operationName string) ([]byte, error) {
es := graph.NewExecutableSchema(graph.Config{
Resolvers: &graph.Resolver{Core: core},
// This is exported so it can be used by the MCP server.
func ExecuteQuery(query string, variables map[string]any, operationName string) ([]byte, error) {
es := newExecutableSchemaForCore(core)
return executeQueryWithSchema(es, query, variables, operationName)
}

// newExecutableSchemaForCore creates a GraphQL executable schema for a given core.
// This allows different commands to use different core instances.
func newExecutableSchemaForCore(c *beancore.Core) graphql.ExecutableSchema {
return graph.NewExecutableSchema(graph.Config{
Resolvers: &graph.Resolver{Core: c},
})
}

// executeQueryWithSchema runs a GraphQL query using the provided schema.
func executeQueryWithSchema(es graphql.ExecutableSchema, query string, variables map[string]any, operationName string) ([]byte, error) {
exec := executor.New(es)

ctx := graphql.StartOperationTrace(context.Background())
Expand Down
172 changes: 172 additions & 0 deletions cmd/mcp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package cmd

import (
"bytes"
"context"
_ "embed"
"fmt"
"os"
"sync"
"text/template"

"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/spf13/cobra"
"github.com/hmans/beans/internal/beancore"
"github.com/hmans/beans/internal/config"
)

//go:embed mcp.tmpl
var mcpToolTemplate string

// GraphQLInput is the input schema for the beans_graphql tool.
type GraphQLInput struct {
Query string `json:"query" jsonschema:"The GraphQL query or mutation to execute"`
Variables map[string]any `json:"variables,omitempty" jsonschema:"Optional variables for the query"`
OperationName string `json:"operationName,omitempty" jsonschema:"Optional operation name for multi-operation documents"`
}

// mcpCore holds the lazily-initialized core for MCP operations.
var (
mcpCore *beancore.Core
mcpCoreOnce sync.Once
mcpCoreErr error
)

var mcpCmd = &cobra.Command{
Use: "mcp",
Short: "Run MCP server for AI assistant integration",
Long: `Run an MCP (Model Context Protocol) server that exposes beans functionality
to AI assistants like Claude Code.

The server provides a single 'beans_graphql' tool that accepts GraphQL queries
and mutations, giving AI assistants full access to query and manage beans.

To use with Claude Code, add to your MCP configuration:
{
"mcpServers": {
"beans": {
"command": "beans",
"args": ["mcp"]
}
}
}`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runMCPServer()
},
}

func runMCPServer() error {
server := mcp.NewServer(&mcp.Implementation{
Name: "beans",
Version: "0.1.0",
}, nil)

// Generate tool description with schema and usage docs
toolDescription, err := generateMCPToolDescription()
if err != nil {
return fmt.Errorf("generating tool description: %w", err)
}

// Register the single beans_graphql tool
mcp.AddTool(server, &mcp.Tool{
Name: "beans_graphql",
Description: toolDescription,
}, handleGraphQLTool)

// Run the server on stdio
return server.Run(context.Background(), &mcp.StdioTransport{})
}

// handleGraphQLTool handles calls to the beans_graphql tool.
func handleGraphQLTool(ctx context.Context, req *mcp.CallToolRequest, args GraphQLInput) (*mcp.CallToolResult, any, error) {
// Lazily initialize core on first tool call
if err := initMCPCore(); err != nil {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{&mcp.TextContent{Text: err.Error()}},
}, nil, nil
}

// Execute the GraphQL query using the MCP-specific core
result, err := executeMCPQuery(args.Query, args.Variables, args.OperationName)
if err != nil {
return &mcp.CallToolResult{
IsError: true,
Content: []mcp.Content{&mcp.TextContent{Text: err.Error()}},
}, nil, nil
}

return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: string(result)}},
}, nil, nil
}

// initMCPCore initializes the core for MCP operations.
// It's called lazily on the first tool call.
func initMCPCore() error {
mcpCoreOnce.Do(func() {
// Search upward for .beans.yml config
cwd, err := os.Getwd()
if err != nil {
mcpCoreErr = fmt.Errorf("getting current directory: %w", err)
return
}

mcpCfg, err := config.LoadFromDirectory(cwd)
if err != nil {
mcpCoreErr = fmt.Errorf("no beans project found in %s or parent directories. Run 'beans init' to create one", cwd)
return
}

// Use path from config
root := mcpCfg.ResolveBeansPath()

// Verify it exists
if info, statErr := os.Stat(root); statErr != nil || !info.IsDir() {
mcpCoreErr = fmt.Errorf("no .beans directory found at %s. Run 'beans init' to create one", root)
return
}

mcpCore = beancore.New(root, mcpCfg)
if err := mcpCore.Load(); err != nil {
mcpCoreErr = fmt.Errorf("loading beans: %w", err)
return
}
})

return mcpCoreErr
}

// executeMCPQuery runs a GraphQL query using the MCP-specific core.
// This is separate from ExecuteQuery to use the lazily-initialized mcpCore.
func executeMCPQuery(query string, variables map[string]any, operationName string) ([]byte, error) {
es := newExecutableSchemaForCore(mcpCore)
return executeQueryWithSchema(es, query, variables, operationName)
}

// generateMCPToolDescription generates the tool description from the template.
func generateMCPToolDescription() (string, error) {
tmpl, err := template.New("mcp").Parse(mcpToolTemplate)
if err != nil {
return "", err
}

data := promptData{
GraphQLSchema: GetGraphQLSchema(),
Types: config.DefaultTypes,
Statuses: config.DefaultStatuses,
Priorities: config.DefaultPriorities,
}

var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}

return buf.String(), nil
}

func init() {
rootCmd.AddCommand(mcpCmd)
}
62 changes: 62 additions & 0 deletions cmd/mcp.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
Execute GraphQL queries and mutations against the beans issue tracker.

Beans is a file-based issue tracker where issues ("beans") are stored as markdown files.

## Usage

Send a GraphQL query or mutation as the `query` parameter. Optionally include `variables` and `operationName`.

## Example Queries

```graphql
# List all beans
{ beans { id title status type } }

# Get a specific bean by ID
{ bean(id: "abc") { title status body } }

# Filter beans by status
{ beans(filter: { status: ["todo", "in-progress"] }) { id title } }

# Find actionable beans (not completed, not blocked)
{ beans(filter: { excludeStatus: ["completed", "scrapped", "draft"], isBlocked: false }) { id title status type priority } }

# Search by text
{ beans(filter: { search: "authentication" }) { id title } }

# Get bean with relationships
{ bean(id: "abc") { title parent { title } children { title status } blockedBy { title } } }
```

## Example Mutations

```graphql
# Create a new bean
mutation { createBean(input: { title: "Fix login bug", type: "bug", status: "todo" }) { id title } }

# Update a bean's status
mutation { updateBean(id: "abc", input: { status: "completed" }) { id status } }

# Set a bean's parent
mutation { setParent(id: "abc", parentId: "xyz") { id parent { title } } }
```

## GraphQL Schema

```graphql
{{.GraphQLSchema}}```

## Issue Types
{{range .Types}}
- **{{.Name}}**{{if .Description}}: {{.Description}}{{end}}
{{- end}}

## Statuses
{{range .Statuses}}
- **{{.Name}}**{{if .Description}}: {{.Description}}{{end}}
{{- end}}

## Priorities
{{range .Priorities}}
- **{{.Name}}**{{if .Description}}: {{.Description}}{{end}}
{{- end}}
Loading