# Directory Structure
```
├── .github
│ └── workflows
│ ├── lint.yml
│ ├── release.yml
│ └── test.yml
├── .gitignore
├── .golangci.yaml
├── .goreleaser.yaml
├── color.go
├── Dockerfile
├── Dockerfile.goreleaser
├── go.mod
├── go.sum
├── LICENSE
├── logs
│ └── .gitignore
├── main_test.go
├── main.go
├── Makefile
├── min-coverage
├── prompts
│ ├── _git_commit_role.tmpl
│ ├── git_amend_commit.tmpl
│ ├── git_squash_commit.tmpl
│ └── git_stage_commit.tmpl
├── prompts_parser_test.go
├── prompts_parser.go
├── prompts_server_test.go
├── prompts_server.go
├── README.md
└── testdata
├── _content.tmpl
├── _footer.tmpl
├── _greeting_body.tmpl
├── _header.tmpl
├── conditional_greeting.tmpl
├── greeting_with_partials.tmpl
├── greeting.tmpl
├── logical_operators.tmpl
├── multiple_partials.tmpl
├── range_scalars.tmpl
├── range_structs.tmpl
└── with_object.tmpl
```
# Files
--------------------------------------------------------------------------------
/logs/.gitignore:
--------------------------------------------------------------------------------
```
*
!.gitignore
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
.idea/
vendor/
dist/
mcp-prompt-engine
coverage.out
*.log
.mcp.json
```
--------------------------------------------------------------------------------
/.golangci.yaml:
--------------------------------------------------------------------------------
```yaml
version: "2"
linters:
exclusions:
rules:
- path: _test\.go
linters:
- errcheck
- path: mcptest
linters:
- errcheck
```
--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
```yaml
project_name: mcp-prompt-engine
before:
hooks:
- go mod tidy
- go generate ./...
builds:
- env:
- CGO_ENABLED=0
goos:
- linux
- windows
- darwin
goarch:
- amd64
- arm64
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.goVersion={{.Env.GO_VERSION}}
main: .
binary: mcp-prompt-engine
archives:
- format: tar.gz
name_template: >-
{{ .ProjectName }}_
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
format_overrides:
- goos: windows
format: zip
checksum:
name_template: 'checksums.txt'
snapshot:
name_template: "{{ incpatch .Version }}-next"
changelog:
sort: asc
filters:
exclude:
- '^docs:'
- '^test:'
dockers:
- image_templates:
- "ghcr.io/vasayxtx/mcp-prompt-engine:{{ .Version }}"
- "ghcr.io/vasayxtx/mcp-prompt-engine:latest"
dockerfile: Dockerfile.goreleaser
use: buildx
goos: linux
goarch: amd64
build_flag_templates:
- "--platform=linux/amd64"
- "--label=org.opencontainers.image.title={{.ProjectName}}"
- "--label=org.opencontainers.image.description=MCP Prompt Engine"
- "--label=org.opencontainers.image.url=https://github.com/vasayxtx/mcp-prompt-engine"
- "--label=org.opencontainers.image.source=https://github.com/vasayxtx/mcp-prompt-engine"
- "--label=org.opencontainers.image.version={{.Version}}"
- "--label=org.opencontainers.image.created={{.Date}}"
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
- "--label=org.opencontainers.image.licenses=MIT"
release:
github:
owner: vasayxtx
name: mcp-prompt-engine
draft: false
prerelease: auto
name_template: "{{.ProjectName}}-v{{.Version}}"
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
# MCP Prompt Engine
[](https://goreportcard.com/report/github.com/vasayxtx/mcp-prompt-engine)
[](https://github.com/vasayxtx/mcp-prompt-engine/releases)
[](https://opensource.org/licenses/MIT)
[](https://pkg.go.dev/github.com/vasayxtx/mcp-prompt-engine)
A Model Control Protocol (MCP) server for managing and serving dynamic prompt templates using elegant and powerful text template engine.
Create reusable, logic-driven prompts with variables, partials, and conditionals that can be served to any [compatible MCP client](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/clients.mdx) like Claude Code, Claude Desktop, Gemini CLI, VSCode with Copilot, etc.
## Key Features
- **MCP Compatible**: Works out-of-the-box with any [MCP client](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/docs/clients.mdx) that supports [prompts](https://modelcontextprotocol.io/docs/concepts/prompts).
- **Powerful Go Templates**: Utilizes the full power of Go [text/template](https://pkg.go.dev/text/template) syntax, including variables, conditionals, loops, and more.
- **Reusable Partials**: Define common components in partial templates (e.g., `_header.tmpl`) and reuse them across your prompts.
- **Prompt Arguments**: All template variables are automatically exposed as MCP prompt arguments, allowing dynamic input from clients.
- **Hot-Reload**: Automatically detects changes to your prompt files and reloads them without restarting the server.
- **Rich CLI**: A modern command-line interface to list, validate, and render templates for easy development and testing.
- **Smart Argument Handling**:
- Automatically parses JSON arguments (booleans, numbers, arrays, objects).
- Injects environment variables as fallbacks for template arguments.
- **Containerized**: Full Docker support for easy deployment and integration.
## Getting Started
### 1. Installation
Install using Go:
```bash
go install github.com/vasayxtx/mcp-prompt-engine@latest
```
(For other methods like Docker or pre-built binaries, see the [Installation section](#installation) below.)
### 2. Create a Prompt
Create a `prompts` directory and add a template file. Let's create a prompt to help write a Git commit message.
First, create a reusable partial named `prompts/_git_commit_role.tmpl`:
```go
{{ define "_git_commit_role" }}
You are an expert programmer specializing in writing clear, concise, and conventional Git commit messages.
Commit message must strictly follow the Conventional Commits specification.
The final commit message you generate must be formatted exactly as follows:
```
<type>: A brief, imperative-tense summary of changes
[Optional longer description, explaining the "why" of the change. Use dash points for clarity.]
```
{{ if .type -}}
Use {{.type}} as a type.
{{ end }}
{{ end }}
```
Now, create a main prompt `prompts/git_stage_commit.tmpl` that uses this partial:
```go
{{- /* Commit currently staged changes */ -}}
{{- template "_git_commit_role" . -}}
Your task is to commit all currently staged changes.
To understand the context, analyze the staged code using the command: `git diff --staged`
Based on that analysis, commit staged changes using a suitable commit message.
```
### 3. Validate Your Prompt
Validate your prompt to ensure it has no syntax errors:
```bash
mcp-prompt-engine validate git_stage_commit
✓ git_stage_commit.tmpl - Valid
```
### 4. Connect MCP Server to Your Client
Add MCP Server to your MCP client. See [Connecting to Clients](#connecting-to-clients) for configuration examples.
### 5. Use Your Prompt
Your `git_stage_commit` prompt will now be available in your client!
For example, in Claude Desktop, you can select the `git_stage_commit` prompt, provide the `type` MCP Prompt argument and get a generated prompt that will help you to do a commit with a perfect message.
In Claude Code or Gemini CLI, you can start typing `/git_stage_commit` and it will suggest the prompt with the provided arguments that will be executed after you select it.
---
## Installation
### Pre-built Binaries
Download the latest release for your OS from the [GitHub Releases page](https://github.com/vasayxtx/mcp-prompt-engine/releases).
### Build from Source
```bash
git clone https://github.com/vasayxtx/mcp-prompt-engine.git
cd mcp-prompt-engine
make build
```
### Docker
A pre-built Docker image is available. Mount your local `prompts` and `logs` directories to the container.
```bash
# Pull and run the pre-built image from GHCR
docker run -i --rm \
-v /path/to/your/prompts:/app/prompts:ro \
-v /path/to/your/logs:/app/logs \
ghcr.io/vasayxtx/mcp-prompt-engine
```
You can also build the image locally with `make docker-build`.
---
## Usage
### Creating Prompt Templates
Create a directory to store your prompt templates. Each template should be a `.tmpl` file using Go's [text/template](https://pkg.go.dev/text/template) syntax with the following format:
```go
{{/* Brief description of the prompt */}}
Your prompt text here with {{.template_variable}} placeholders.
```
The first line comment (`{{/* description */}}`) is used as the prompt description, and the rest of the file is the prompt template.
Partial templates should be prefixed with an underscore (e.g., `_header.tmpl`) and can be included in other templates using `{{template "partial_name" .}}`.
### Template Syntax
The server uses Go's `text/template` engine, which provides powerful templating capabilities:
- **Variables**: `{{.variable_name}}` - Access template variables
- **Built-in variables**:
- `{{.date}}` - Current date and time
- **Conditionals**: `{{if .condition}}...{{end}}`, `{{if .condition}}...{{else}}...{{end}}`
- **Logical operators**: `{{if and .condition1 .condition2}}...{{end}}`, `{{if or .condition1 .condition2}}...{{end}}`
- **Loops**: `{{range .items}}...{{end}}`
- **Template inclusion**: `{{template "partial_name" .}}` or `{{template "partial_name" dict "key" "value"}}`
See the [Go text/template documentation](https://pkg.go.dev/text/template) for more details on syntax and features.
### JSON Argument Parsing
The server automatically parses argument values as JSON when possible, enabling rich data types in templates:
- **Booleans**: `true`, `false` → Go boolean values
- **Numbers**: `42`, `3.14` → Go numeric values
- **Arrays**: `["item1", "item2"]` → Go slices for use with `{{range}}`
- **Objects**: `{"key": "value"}` → Go maps for structured data
- **Strings**: Invalid JSON falls back to string values
This allows for advanced template operations like:
```go
{{range .items}}Item: {{.}}{{end}}
{{if .enabled}}Feature is enabled{{end}}
{{.config.timeout}} seconds
```
To disable JSON parsing and treat all arguments as strings, use the `--disable-json-args` flag for the `serve` and `render` commands.
### CLI Commands
The CLI is your main tool for managing and testing templates.
By default, it looks for templates in the `./prompts` directory, but you can specify a different directory with the `--prompts` flag.
**1. List Templates**
```bash
# See a simple list of available prompts
mcp-prompt-engine list
# See a detailed view with descriptions and variables
mcp-prompt-engine list --verbose
```
**2. Render a Template**
Render a prompt directly in your terminal, providing arguments with the `-a` or `--arg` flag.
It will automatically inject environment variables as fallbacks for any missing arguments. For example, if you have an environment variable `TYPE=fix`, it will be injected into the template as `{{.type}}`.
```bash
# Render the git commit prompt, providing the 'type' variable
mcp-prompt-engine render git_stage_commit --arg type=feat
```
**3. Validate Templates**
Check all your templates for syntax errors. The command will return an error if any template is invalid.
```bash
# Validate all templates in the directory
mcp-prompt-engine validate
# Validate a single template
mcp-prompt-engine validate git_stage_commit
```
**4. Start the Server**
Run the MCP server to make your prompts available to clients.
```bash
# Run with default settings (looks for ./prompts)
mcp-prompt-engine serve
# Specify a different prompts directory and a log file
mcp-prompt-engine --prompts /path/to/prompts serve --log-file ./server.log
```
---
## Connecting to Clients
To use this engine with any client that supports MCP Prompts, add a new entry to its MCP servers configuration.
Global configuration locations (MacOS):
- Claude Code: `~/.claude.json` (`mcpServers` section)
- Claude Desktop: `~/Library/Application\ Support/Claude/claude_desktop_config.json` (`mcpServers` section)
- Gemini CLI: `~/.gemini/settings.json` (`mcpServers` section)
**Example for a local binary:**
```json
{
"prompts": {
"command": "/path/to/your/mcp-prompt-engine",
"args": [
"--prompts", "/path/to/your/prompts",
"serve",
"--quiet"
]
}
}
```
**Example for Docker:**
```json
{
"mcp-prompt-engine-docker": {
"command": "docker",
"args": [
"run", "-i", "--rm",
"-v", "/path/to/your/prompts:/app/prompts:ro",
"-v", "/path/to/your/logs:/app/logs",
"ghcr.io/vasayxtx/mcp-prompt-engine"
]
}
}
```
## License
This project is licensed under the MIT License - see the [LICENSE](./LICENSE) file for details.
```
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
```yaml
name: Test
on:
push:
branches:
- main
pull_request:
branches:
- main
permissions:
contents: read
jobs:
test:
name: Test
strategy:
matrix:
go: [ '1.24' ]
fail-fast: true
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go ${{ matrix.go }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Run tests
run: make test
```
--------------------------------------------------------------------------------
/.github/workflows/lint.yml:
--------------------------------------------------------------------------------
```yaml
name: Lint
on:
push:
branches:
- main
pull_request:
branches:
- main
permissions:
contents: read
jobs:
lint:
name: Lint
strategy:
matrix:
go: [ '1.24' ]
fail-fast: true
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Go ${{ matrix.go }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Run GolangCI-Lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.1
```
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
```dockerfile
FROM golang:1.24-alpine AS builder
RUN apk add --no-cache git
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o mcp-prompt-engine .
FROM alpine:latest
RUN apk --no-cache add ca-certificates
RUN addgroup -g 1001 -S mcpuser && \
adduser -S -D -H -u 1001 -h /app -s /sbin/nologin -G mcpuser mcpuser
WORKDIR /app
COPY --from=builder /app/mcp-prompt-engine .
RUN mkdir -p /app/prompts /app/logs && chown -R mcpuser:mcpuser /app
USER mcpuser
VOLUME ["/app/prompts", "/app/logs"]
ENV MCP_PROMPTS_DIR=/app/prompts
CMD ["./mcp-prompt-engine", "serve", "--quiet", "--log-file", "/app/logs/mcp-prompt-engine.log"]
```
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
```yaml
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
packages: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
id: setup-go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
with:
distribution: goreleaser
version: ~> v1
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GO_VERSION: ${{ steps.setup-go.outputs.go-version }}
```
--------------------------------------------------------------------------------
/color.go:
--------------------------------------------------------------------------------
```go
package main
import (
"fmt"
"github.com/fatih/color"
)
type ColorMode string
const (
colorModeNever ColorMode = "never"
colorModeAlways ColorMode = "always"
colorModeAuto ColorMode = "auto"
)
var colorModesCommaSeparatedList = fmt.Sprintf("%s, %s, %s", colorModeAuto, colorModeAlways, colorModeNever)
// Color utility functions for consistent styling
var (
// Status indicators
successIcon func(...interface{}) string
errorIcon func(...interface{}) string
warningIcon func(...interface{}) string
// Text colors
successText func(...interface{}) string
errorText func(...interface{}) string
infoText func(...interface{}) string
highlightText func(...interface{}) string
// Specific formatters
templateText func(...interface{}) string
pathText func(...interface{}) string
)
// initializeColors sets up color functions based on color mode
func initializeColors(colorMode ColorMode) {
switch colorMode {
case colorModeNever:
color.NoColor = true
case colorModeAlways:
color.NoColor = false
case colorModeAuto:
// fatih/color automatically detects TTY using go-isatty
// NoColor will be set to true if not a TTY
default:
// Default to auto
}
// Initialize color functions
successIcon = color.New(color.FgGreen, color.Bold).SprintFunc()
errorIcon = color.New(color.FgRed, color.Bold).SprintFunc()
warningIcon = color.New(color.FgYellow, color.Bold).SprintFunc()
successText = color.New(color.FgGreen).SprintFunc()
errorText = color.New(color.FgRed).SprintFunc()
infoText = color.New(color.FgBlue).SprintFunc()
highlightText = color.New(color.FgCyan, color.Bold).SprintFunc()
templateText = color.New(color.FgMagenta, color.Bold).SprintFunc()
pathText = color.New(color.FgBlue).SprintFunc()
// Apply icons with color
successIcon = func(args ...interface{}) string {
return color.New(color.FgGreen, color.Bold).Sprint("✓")
}
errorIcon = func(args ...interface{}) string {
return color.New(color.FgRed, color.Bold).Sprint("✗")
}
warningIcon = func(args ...interface{}) string {
return color.New(color.FgYellow, color.Bold).Sprint("⚠")
}
}
func init() {
initializeColors(colorModeAuto)
}
```
--------------------------------------------------------------------------------
/prompts_parser.go:
--------------------------------------------------------------------------------
```go
package main
import (
"bytes"
"fmt"
"os"
"path/filepath"
"strings"
"text/template"
"text/template/parse"
)
type PromptsParser struct {
}
func (pp *PromptsParser) ParseDir(promptsDir string) (*template.Template, error) {
tmpl := template.New("base").Funcs(template.FuncMap{
"dict": dict,
})
var err error
tmpl, err = tmpl.ParseGlob(filepath.Join(promptsDir, "*"+templateExt))
if err != nil {
return nil, fmt.Errorf("parse template glob %q: %w", filepath.Join(promptsDir, "*"+templateExt), err)
}
return tmpl, nil
}
func (pp *PromptsParser) ExtractPromptDescriptionFromFile(filePath string) (string, error) {
content, err := os.ReadFile(filePath)
if err != nil {
return "", fmt.Errorf("read file: %w", err)
}
content = bytes.TrimSpace(content)
var firstLine string
if idx := bytes.IndexByte(content, '\n'); idx != -1 {
firstLine = string(content[:idx])
} else {
firstLine = string(content)
}
firstLine = strings.TrimSpace(firstLine)
for _, c := range [...][2]string{
{"{{/*", "*/}}"},
{"{{- /*", "*/}}"},
{"{{/*", "*/ -}}"},
{"{{- /*", "*/ -}}"},
} {
if strings.HasPrefix(firstLine, c[0]) && strings.HasSuffix(firstLine, c[1]) {
comment := firstLine
comment = strings.TrimPrefix(comment, c[0])
comment = strings.TrimSuffix(comment, c[1])
return strings.TrimSpace(comment), nil
}
}
return "", nil
}
// ExtractPromptArgumentsFromTemplate analyzes template to find field references using template tree traversal,
// leveraging text/template built-in functionality to automatically resolve partials
func (pp *PromptsParser) ExtractPromptArgumentsFromTemplate(
tmpl *template.Template, templateName string,
) ([]string, error) {
targetTemplate := tmpl.Lookup(templateName)
if targetTemplate == nil {
if strings.HasSuffix(templateName, templateExt) {
return nil, fmt.Errorf("template %q not found", templateName)
}
if targetTemplate = tmpl.Lookup(templateName + templateExt); targetTemplate == nil {
return nil, fmt.Errorf("template %q or %q not found", templateName, templateName+templateExt)
}
}
argsMap := make(map[string]struct{})
builtInFields := map[string]struct{}{"date": {}}
processedTemplates := make(map[string]bool)
// Extract arguments from the target template and all referenced templates recursively
err := pp.walkNodes(targetTemplate.Root, argsMap, builtInFields, tmpl, processedTemplates, []string{})
if err != nil {
return nil, err
}
args := make([]string, 0, len(argsMap))
for arg := range argsMap {
args = append(args, arg)
}
return args, nil
}
// walkNodes recursively walks the template parse tree to find variable references,
// automatically resolving template calls to include variables from referenced templates
func (pp *PromptsParser) walkNodes(
node parse.Node,
argsMap map[string]struct{},
builtInFields map[string]struct{},
tmpl *template.Template,
processedTemplates map[string]bool,
path []string,
) error {
if node == nil {
return nil
}
switch n := node.(type) {
case *parse.ActionNode:
return pp.walkNodes(n.Pipe, argsMap, builtInFields, tmpl, processedTemplates, path)
case *parse.IfNode:
if err := pp.walkNodes(n.Pipe, argsMap, builtInFields, tmpl, processedTemplates, path); err != nil {
return err
}
if err := pp.walkNodes(n.List, argsMap, builtInFields, tmpl, processedTemplates, path); err != nil {
return err
}
return pp.walkNodes(n.ElseList, argsMap, builtInFields, tmpl, processedTemplates, path)
case *parse.RangeNode:
if err := pp.walkNodes(n.Pipe, argsMap, builtInFields, tmpl, processedTemplates, path); err != nil {
return err
}
if err := pp.walkNodes(n.List, argsMap, builtInFields, tmpl, processedTemplates, path); err != nil {
return err
}
return pp.walkNodes(n.ElseList, argsMap, builtInFields, tmpl, processedTemplates, path)
case *parse.WithNode:
if err := pp.walkNodes(n.Pipe, argsMap, builtInFields, tmpl, processedTemplates, path); err != nil {
return err
}
if err := pp.walkNodes(n.List, argsMap, builtInFields, tmpl, processedTemplates, path); err != nil {
return err
}
return pp.walkNodes(n.ElseList, argsMap, builtInFields, tmpl, processedTemplates, path)
case *parse.ListNode:
if n != nil {
for _, child := range n.Nodes {
if err := pp.walkNodes(child, argsMap, builtInFields, tmpl, processedTemplates, path); err != nil {
return err
}
}
}
case *parse.PipeNode:
if n != nil {
for _, cmd := range n.Cmds {
if err := pp.walkNodes(cmd, argsMap, builtInFields, tmpl, processedTemplates, path); err != nil {
return err
}
}
}
case *parse.CommandNode:
if n != nil {
for _, arg := range n.Args {
if err := pp.walkNodes(arg, argsMap, builtInFields, tmpl, processedTemplates, path); err != nil {
return err
}
}
}
case *parse.FieldNode:
if len(n.Ident) > 0 {
fieldName := strings.ToLower(n.Ident[0])
if _, isBuiltIn := builtInFields[fieldName]; !isBuiltIn {
argsMap[fieldName] = struct{}{}
}
}
case *parse.VariableNode:
if len(n.Ident) > 0 {
fieldName := strings.ToLower(n.Ident[0])
// Skip variable names that start with $ (template variables)
if !strings.HasPrefix(fieldName, "$") {
if _, isBuiltIn := builtInFields[fieldName]; !isBuiltIn {
argsMap[fieldName] = struct{}{}
}
}
}
case *parse.TemplateNode:
templateName := n.Name
// Check for cycles
for _, ancestor := range path {
if ancestor == templateName {
return fmt.Errorf("cyclic partial reference detected: %s", strings.Join(append(path, templateName), " -> "))
}
}
if !processedTemplates[templateName] {
processedTemplates[templateName] = true
// Try to find the template by name or name + extension
var referencedTemplate *template.Template
if referencedTemplate = tmpl.Lookup(templateName); referencedTemplate == nil && !strings.HasSuffix(templateName, templateExt) {
referencedTemplate = tmpl.Lookup(templateName + templateExt)
}
if referencedTemplate == nil || referencedTemplate.Tree == nil {
return fmt.Errorf("referenced template %q not found in %q", templateName, tmpl.Name())
}
if err := pp.walkNodes(referencedTemplate.Root, argsMap, builtInFields, tmpl, processedTemplates, append(path, templateName)); err != nil {
return err
}
}
return pp.walkNodes(n.Pipe, argsMap, builtInFields, tmpl, processedTemplates, path)
}
return nil
}
// dict creates a map from key-value pairs for template usage
func dict(values ...interface{}) map[string]interface{} {
if len(values)%2 != 0 {
return nil
}
result := make(map[string]interface{})
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil
}
result[key] = values[i+1]
}
return result
}
```
--------------------------------------------------------------------------------
/prompts_server.go:
--------------------------------------------------------------------------------
```go
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"text/template"
"time"
"github.com/fsnotify/fsnotify"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
)
type PromptsServer struct {
mcpServer *server.MCPServer
parser *PromptsParser
promptsDir string
enableJSONArgs bool
logger *slog.Logger
watcher *fsnotify.Watcher
}
// NewPromptsServer creates a new PromptsServer instance that serves prompts from the specified directory.
func NewPromptsServer(
promptsDir string, enableJSONArgs bool, logger *slog.Logger,
) (promptsServer *PromptsServer, err error) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("create file watcher: %w", err)
}
defer func() {
if err != nil {
if closeErr := watcher.Close(); closeErr != nil {
logger.Error("Failed to close file watcher", "error", closeErr)
}
}
}()
if err = watcher.Add(promptsDir); err != nil {
return nil, fmt.Errorf("add prompts directory to watcher: %w", err)
}
srvHooks := &server.Hooks{}
srvHooks.AddBeforeGetPrompt(func(ctx context.Context, id any, message *mcp.GetPromptRequest) {
logger.Info("Received prompt request",
"id", id, "params_name", message.Params.Name, "params_args", message.Params.Arguments)
})
srvHooks.AddAfterGetPrompt(func(ctx context.Context, id any, message *mcp.GetPromptRequest, result *mcp.GetPromptResult) {
logger.Info("Processed prompt request",
"id", id, "params_name", message.Params.Name, "params_args", message.Params.Arguments)
})
mcpServer := server.NewMCPServer(
"Prompts Engine MCP Server",
"1.0.0",
server.WithLogging(),
server.WithRecovery(),
server.WithHooks(srvHooks),
server.WithPromptCapabilities(true),
)
promptsServer = &PromptsServer{
mcpServer: mcpServer,
parser: &PromptsParser{},
promptsDir: promptsDir,
enableJSONArgs: enableJSONArgs,
logger: logger,
watcher: watcher,
}
if err = promptsServer.reloadPrompts(); err != nil {
return nil, fmt.Errorf("reload prompts: %w", err)
}
return promptsServer, nil
}
func (ps *PromptsServer) Close() error {
if ps.watcher != nil {
if err := ps.watcher.Close(); err != nil {
return err
}
ps.watcher = nil
}
return nil
}
// ServeStdio starts the MCP server with stdio transport and file watching.
func (ps *PromptsServer) ServeStdio(ctx context.Context, stdin io.Reader, stdout io.Writer) error {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
ps.startWatcher(ctx)
}()
srvErrChan := make(chan error, 1)
wg.Add(1)
go func() {
defer wg.Done()
ps.logger.Info("Starting stdio server")
srvErrChan <- server.NewStdioServer(ps.mcpServer).Listen(ctx, stdin, stdout)
}()
var srvErr error
select {
case srvErr = <-srvErrChan:
if srvErr != nil {
ps.logger.Error("Stdio server error", "error", srvErr)
}
case <-ctx.Done():
ps.logger.Info("Context cancelled, stopping server")
}
wg.Wait()
return srvErr
}
func (ps *PromptsServer) loadServerPrompts() ([]server.ServerPrompt, error) {
tmpl, err := ps.parser.ParseDir(ps.promptsDir)
if err != nil {
return nil, fmt.Errorf("parse all prompts: %w", err)
}
files, err := os.ReadDir(ps.promptsDir)
if err != nil {
return nil, fmt.Errorf("read prompts directory: %w", err)
}
var serverPrompts []server.ServerPrompt
for _, file := range files {
if !isTemplateFile(file) {
continue
}
filePath := filepath.Join(ps.promptsDir, file.Name())
templateName := file.Name()
if tmpl.Lookup(templateName) == nil {
return nil, fmt.Errorf("template %q not found", templateName)
}
var description string
if description, err = ps.parser.ExtractPromptDescriptionFromFile(filePath); err != nil {
return nil, fmt.Errorf("extract prompt description from %q template file: %w", filePath, err)
}
var args []string
if args, err = ps.parser.ExtractPromptArgumentsFromTemplate(tmpl, templateName); err != nil {
return nil, fmt.Errorf("extract prompt arguments from %q template file: %w", filePath, err)
}
envArgs := make(map[string]string)
var promptArgs []string
for _, arg := range args {
// Convert arg to TITLE_CASE for env var
envVarName := strings.ToUpper(arg)
if envValue, exists := os.LookupEnv(envVarName); exists {
envArgs[arg] = envValue
} else {
promptArgs = append(promptArgs, arg)
}
}
promptOpts := []mcp.PromptOption{
mcp.WithPromptDescription(description),
}
for _, promptArg := range promptArgs {
promptOpts = append(promptOpts, mcp.WithArgument(promptArg))
}
promptName := strings.TrimSuffix(file.Name(), templateExt)
serverPrompts = append(serverPrompts, server.ServerPrompt{
Prompt: mcp.NewPrompt(promptName, promptOpts...),
Handler: ps.makeMCPHandler(tmpl, templateName, description, envArgs),
})
ps.logger.Info("Prompt will be registered",
"name", promptName,
"description", description,
"prompt_args", promptArgs,
"env_args", envArgs)
}
return serverPrompts, nil
}
func (ps *PromptsServer) reloadPrompts() error {
newServerPrompts, err := ps.loadServerPrompts()
if err != nil {
return fmt.Errorf("load server prompts: %w", err)
}
ps.mcpServer.SetPrompts(newServerPrompts...)
ps.logger.Info("Prompts registered", "count", len(newServerPrompts))
return nil
}
func (ps *PromptsServer) makeMCPHandler(
tmpl *template.Template, templateName string, description string, envArgs map[string]string,
) func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
return func(ctx context.Context, request mcp.GetPromptRequest) (*mcp.GetPromptResult, error) {
data := make(map[string]interface{})
data["date"] = time.Now().Format("2006-01-02 15:04:05")
for arg, value := range envArgs {
data[arg] = value
}
parseMCPArgs(request.Params.Arguments, ps.enableJSONArgs, data)
var result strings.Builder
if err := tmpl.ExecuteTemplate(&result, templateName, data); err != nil {
return nil, fmt.Errorf("execute template %q: %w", templateName, err)
}
return mcp.NewGetPromptResult(
description,
[]mcp.PromptMessage{
mcp.NewPromptMessage(
mcp.RoleUser,
mcp.NewTextContent(strings.TrimSpace(result.String())),
),
},
), nil
}
}
// startWatcher monitors file system changes and reloads prompts
func (ps *PromptsServer) startWatcher(ctx context.Context) {
ps.logger.Info("Started watching prompts directory for changes", "dir", ps.promptsDir)
for {
select {
case event, ok := <-ps.watcher.Events:
if !ok {
return
}
if !strings.HasSuffix(event.Name, templateExt) {
continue
}
ps.logger.Info("Prompt template file changed", "file", event.Name, "operation", event.Op.String())
if err := ps.reloadPrompts(); err != nil {
ps.logger.Error("Failed to reload prompts", "error", err)
}
case err, ok := <-ps.watcher.Errors:
if !ok {
return
}
ps.logger.Error("File watcher error", "error", err)
case <-ctx.Done():
ps.logger.Info("Stopping prompts watcher due to context cancellation")
return
}
}
}
// parseMCPArgs attempts to parse each argument value as JSON when enableJSONArgs is true.
// If parsing succeeds, stores the parsed value (bool, number, nil, object, etc.) in the data map.
// If parsing fails or JSON parsing is disabled, stores the original string value.
func parseMCPArgs(args map[string]string, enableJSONArgs bool, data map[string]interface{}) {
for key, value := range args {
if enableJSONArgs {
var parsed interface{}
if err := json.Unmarshal([]byte(value), &parsed); err == nil {
data[key] = parsed
continue
}
}
data[key] = value
}
}
func isTemplateFile(file os.DirEntry) bool {
return file.Type().IsRegular() && strings.HasSuffix(file.Name(), templateExt) && !strings.HasPrefix(file.Name(), "_")
}
```
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
```go
package main
import (
"bytes"
"context"
"fmt"
"io"
"log"
"log/slog"
"os"
"os/signal"
"path/filepath"
"slices"
"sort"
"strings"
"syscall"
"text/template"
"time"
"github.com/urfave/cli/v3"
)
var (
version = "dev"
commit = "unknown"
goVersion = "unknown"
)
const templateExt = ".tmpl"
func main() {
cmd := &cli.Command{
Name: "mcp-prompt-engine",
Usage: "A Model Control Protocol server for dynamic prompt templates",
Version: fmt.Sprintf("%s (commit: %s, go: %s)", version, commit, goVersion),
Flags: []cli.Flag{
&cli.StringFlag{
Name: "prompts",
Aliases: []string{"p"},
Value: "./prompts",
Usage: "Directory containing prompt template files",
Sources: cli.EnvVars("MCP_PROMPTS_DIR"),
},
&cli.StringFlag{
Name: "color",
Value: "auto",
Usage: "Colorize output: " + colorModesCommaSeparatedList,
Sources: cli.EnvVars("NO_COLOR"),
Action: func(ctx context.Context, cmd *cli.Command, value string) error {
colorMode := ColorMode(value)
if colorMode != colorModeAuto && colorMode != colorModeAlways && colorMode != colorModeNever {
return fmt.Errorf("invalid color value %q, must be one of: "+colorModesCommaSeparatedList, value)
}
return nil
},
},
},
Commands: []*cli.Command{
{
Name: "serve",
Usage: "Start the MCP server",
Action: serveCommand,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "log-file",
Usage: "Path to log file (if not specified, logs to stdout)",
},
&cli.BoolFlag{
Name: "disable-json-args",
Usage: "Disable JSON parsing for arguments (use string-only mode)",
},
&cli.BoolFlag{
Name: "quiet",
Usage: "Suppress non-essential output",
},
},
},
{
Name: "render",
Usage: "Render a template to stdout",
ArgsUsage: "<template_name>",
Action: renderCommand,
Flags: []cli.Flag{
&cli.StringSliceFlag{
Name: "arg",
Aliases: []string{"a"},
Usage: "Template argument in name=value format (repeatable)",
},
&cli.BoolFlag{
Name: "disable-json-args",
Usage: "Disable JSON parsing for arguments (use string-only mode)",
},
},
},
{
Name: "list",
Usage: "List available templates",
Action: listCommand,
Flags: []cli.Flag{
&cli.BoolFlag{
Name: "verbose",
Usage: "Show detailed information about templates",
},
},
},
{
Name: "validate",
Usage: "Validate template syntax",
ArgsUsage: "[template_name]",
Action: validateCommand,
},
{
Name: "version",
Usage: "Show version information",
Action: versionCommand,
},
},
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
colorMode := ColorMode(cmd.String("color"))
initializeColors(colorMode)
// Skip validation for version command
if cmd.Name == "version" {
return ctx, nil
}
// Validate prompts directory exists
promptsDir := cmd.String("prompts")
if _, err := os.Stat(promptsDir); os.IsNotExist(err) {
return ctx, fmt.Errorf("prompts directory '%s' does not exist", promptsDir)
}
return ctx, nil
},
}
if err := cmd.Run(context.Background(), os.Args); err != nil {
log.Fatal(err)
}
}
// serveCommand starts the MCP server
func serveCommand(ctx context.Context, cmd *cli.Command) error {
promptsDir := cmd.String("prompts")
logFile := cmd.String("log-file")
enableJSONArgs := !cmd.Bool("disable-json-args")
quiet := cmd.Bool("quiet")
if err := runStdioMCPServer(os.Stdout, promptsDir, logFile, enableJSONArgs, quiet); err != nil {
return fmt.Errorf("%s: %w", errorText("failed to start MCP server"), err)
}
return nil
}
// renderCommand renders a template to stdout
func renderCommand(ctx context.Context, cmd *cli.Command) error {
if cmd.Args().Len() < 1 {
return fmt.Errorf("template name is required\n\nUsage: %s render <template_name>", cmd.Root().Name)
}
promptsDir := cmd.String("prompts")
templateName := cmd.Args().First()
args := cmd.StringSlice("arg")
enableJSONArgs := !cmd.Bool("disable-json-args")
// Parse args into a map
argMap := make(map[string]string)
for _, arg := range args {
parts := strings.SplitN(arg, "=", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid argument format '%s', expected name=value", arg)
}
argMap[parts[0]] = parts[1]
}
if err := renderTemplate(os.Stdout, promptsDir, templateName, argMap, enableJSONArgs); err != nil {
return fmt.Errorf("%s '%s': %w", errorText("failed to render template"), templateText(templateName), err)
}
return nil
}
// listCommand lists available templates
func listCommand(ctx context.Context, cmd *cli.Command) error {
promptsDir := cmd.String("prompts")
verbose := cmd.Bool("verbose")
if err := listTemplates(os.Stdout, promptsDir, verbose); err != nil {
return fmt.Errorf("failed to list templates: %w", err)
}
return nil
}
// validateCommand validates template syntax
func validateCommand(ctx context.Context, cmd *cli.Command) error {
promptsDir := cmd.String("prompts")
var templateName string
if cmd.Args().Len() > 0 {
templateName = cmd.Args().First()
}
if err := validateTemplates(os.Stdout, promptsDir, templateName); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
return nil
}
// versionCommand shows detailed version information
func versionCommand(ctx context.Context, cmd *cli.Command) error {
mustFprintf(os.Stdout, "Version: %s\n", version)
mustFprintf(os.Stdout, "Commit: %s\n", commit)
mustFprintf(os.Stdout, "Go Version: %s\n", goVersion)
return nil
}
func runStdioMCPServer(w io.Writer, promptsDir string, logFile string, enableJSONArgs bool, quiet bool) error {
// Configure logger
logWriter := w
if quiet {
logWriter = io.Discard
}
if logFile != "" {
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return fmt.Errorf("open log file: %w", err)
}
defer func() { _ = file.Close() }()
logWriter = file
}
logger := slog.New(slog.NewTextHandler(logWriter, nil))
// Create PromptsServer instance
promptsSrv, err := NewPromptsServer(promptsDir, enableJSONArgs, logger)
if err != nil {
return fmt.Errorf("new prompts server: %w", err)
}
defer func() {
if closeErr := promptsSrv.Close(); closeErr != nil {
logger.Error("Failed to close prompts server", "error", closeErr)
}
}()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-sigChan
logger.Info("Received shutdown signal, stopping server")
cancel()
}()
return promptsSrv.ServeStdio(ctx, os.Stdin, os.Stdout)
}
// renderTemplate renders a specified template to stdout with resolved partials and environment variables
func renderTemplate(w io.Writer, promptsDir string, templateName string, cliArgs map[string]string, enableJSONArgs bool) error {
templateName = strings.TrimSpace(templateName)
if templateName == "" {
return fmt.Errorf("template name is required")
}
if !strings.HasSuffix(templateName, templateExt) {
templateName += templateExt
}
availableTemplates, err := getAvailableTemplates(promptsDir)
if err != nil {
return err
}
if !slices.Contains(availableTemplates, templateName) {
return fmt.Errorf("template %s not found\n\n%s:\n %s",
errorText(templateName),
infoText("Available templates"), strings.Join(availableTemplates, "\n "))
}
parser := &PromptsParser{}
tmpl, err := parser.ParseDir(promptsDir)
if err != nil {
return fmt.Errorf("parse all prompts: %w", err)
}
args, err := parser.ExtractPromptArgumentsFromTemplate(tmpl, templateName)
if err != nil {
return fmt.Errorf("extract template arguments: %w", err)
}
data := make(map[string]interface{})
data["date"] = time.Now().Format("2006-01-02 15:04:05")
// Parse CLI args with JSON support if enabled
parseMCPArgs(cliArgs, enableJSONArgs, data)
// Resolve variables from CLI args and environment variables
for _, arg := range args {
// Check if already set by CLI args (highest priority)
if _, exists := data[arg]; !exists {
// Fall back to environment variables
envVarName := strings.ToUpper(arg)
if envValue, envExists := os.LookupEnv(envVarName); envExists {
data[arg] = envValue
}
}
}
var result bytes.Buffer
if err = tmpl.ExecuteTemplate(&result, templateName, data); err != nil {
return fmt.Errorf("execute template: %w", err)
}
_, err = w.Write(bytes.TrimSpace(result.Bytes()))
return err
}
// listTemplates lists all available templates in the prompts directory
func listTemplates(w io.Writer, promptsDir string, verbose bool) error {
availableTemplates, err := getAvailableTemplates(promptsDir)
if err != nil {
return err
}
if len(availableTemplates) == 0 {
if verbose {
mustFprintf(w, "No templates found in %s\n", pathText(promptsDir))
}
return nil
}
parser := &PromptsParser{}
var tmpl *template.Template
for _, templateName := range availableTemplates {
if !verbose {
// Simple list without description and variables
mustFprintf(w, "%s\n", templateText(templateName))
continue
}
mustFprintf(w, "%s\n", templateText(templateName))
var description string
if description, err = parser.ExtractPromptDescriptionFromFile(
filepath.Join(promptsDir, templateName),
); err != nil {
mustFprintf(w, "%s\n", errorText(fmt.Sprintf("Error: %v", err)))
} else {
if description != "" {
mustFprintf(w, " Description: %s\n", description)
} else {
mustFprintf(w, " Description:\n")
}
}
if tmpl == nil {
if tmpl, err = parser.ParseDir(promptsDir); err != nil {
return fmt.Errorf("parse all prompts: %w", err)
}
}
var args []string
if args, err = parser.ExtractPromptArgumentsFromTemplate(tmpl, templateName); err != nil {
mustFprintf(w, "%s\n", errorText(fmt.Sprintf("Error: %v", err)))
} else {
if len(args) > 0 {
sort.Strings(args)
mustFprintf(w, " Variables: %s\n", highlightText(strings.Join(args, ", ")))
} else {
mustFprintf(w, " Variables:\n")
}
}
}
return nil
}
// validateTemplates validates template syntax
func validateTemplates(w io.Writer, promptsDir string, templateName string) error {
templateName = strings.TrimSpace(templateName)
if templateName != "" && !strings.HasSuffix(templateName, templateExt) {
templateName += templateExt
}
availableTemplates, err := getAvailableTemplates(promptsDir)
if err != nil {
return err
}
if templateName != "" {
if !slices.Contains(availableTemplates, templateName) {
return fmt.Errorf("template %q not found in %s", templateName, promptsDir)
}
}
if len(availableTemplates) == 0 {
mustFprintf(w, "%s No templates found in %s\n", warningIcon(), pathText(promptsDir))
return nil
}
parser := &PromptsParser{}
tmpl, err := parser.ParseDir(promptsDir)
if err != nil {
return fmt.Errorf("parse prompts directory: %w", err)
}
hasErrors := false
for _, name := range availableTemplates {
if templateName != "" && name != templateName {
continue // Skip if not validating this template
}
// Try to extract arguments (this validates basic syntax)
if _, err = parser.ExtractPromptArgumentsFromTemplate(tmpl, name); err != nil {
mustFprintf(w, "%s %s - %s\n", errorIcon(), templateText(name), errorText(fmt.Sprintf("Error: %v", err)))
hasErrors = true
continue
}
mustFprintf(w, "%s %s - %s\n", successIcon(), templateText(name), successText("Valid"))
}
if hasErrors {
return fmt.Errorf("some templates have validation errors")
}
return nil
}
func getAvailableTemplates(promptsDir string) ([]string, error) {
files, err := os.ReadDir(promptsDir)
if err != nil {
return nil, fmt.Errorf("read prompts directory: %w", err)
}
var templateFiles []string
for _, file := range files {
if !isTemplateFile(file) {
continue
}
templateFiles = append(templateFiles, file.Name())
}
sort.Strings(templateFiles)
return templateFiles, nil
}
func mustFprintf(w io.Writer, format string, a ...interface{}) {
if _, err := fmt.Fprintf(w, format, a...); err != nil {
panic(fmt.Sprintf("Failed to write output: %v", err))
}
}
```
--------------------------------------------------------------------------------
/prompts_parser_test.go:
--------------------------------------------------------------------------------
```go
package main
import (
"os"
"path/filepath"
"sort"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type PromptsParserTestSuite struct {
suite.Suite
parser *PromptsParser
tempDir string
}
func TestPromptsParserTestSuite(t *testing.T) {
suite.Run(t, new(PromptsParserTestSuite))
}
func (s *PromptsParserTestSuite) SetupTest() {
s.parser = &PromptsParser{}
s.tempDir = s.T().TempDir()
}
// TestExtractTemplateArgumentsFromTemplate tests template argument extraction with various scenarios
func (s *PromptsParserTestSuite) TestExtractTemplateArgumentsFromTemplate() {
tests := []struct {
name string
content string
partials map[string]string
expected []string
description string
shouldError bool
}{
{
name: "empty template",
content: "{{/* Empty template */}}\nNo arguments here",
partials: map[string]string{},
expected: []string{},
description: "Empty template",
shouldError: false,
},
{
name: "single argument",
content: "{{/* Single argument template */}}\nHello {{.name}}",
partials: map[string]string{},
expected: []string{"name"},
description: "Single argument template",
shouldError: false,
},
{
name: "multiple arguments",
content: "{{/* Multiple arguments template */}}\nHello {{.name}}, your project is {{.project}} and language is {{.language}}",
partials: map[string]string{},
expected: []string{"name", "project", "language"},
description: "Multiple arguments template",
shouldError: false,
},
{
name: "arguments with built-in date",
content: "{{/* Template with date */}}\nToday is {{.date}} and user is {{.username}}",
partials: map[string]string{},
expected: []string{"username"}, // date is built-in, should be filtered out
description: "Template with date",
shouldError: false,
},
{
name: "template with used partial only",
content: "{{/* Template with used partial only */}}\n{{template \"_header\" dict \"role\" .role \"task\" .task}}\nUser: {{.username}}",
partials: map[string]string{"_header": "You are {{.role}} doing {{.task}}", "_footer": "End with {{.conclusion}}"},
expected: []string{"role", "task", "username"}, // should NOT include conclusion from unused footer
description: "Template with used partial only",
shouldError: false,
},
{
name: "template with multiple used partials",
content: "{{/* Template with multiple partials */}}\n{{template \"_header\" dict \"role\" .role}}\n{{template \"_footer\" dict \"conclusion\" .conclusion}}\nUser: {{.username}}",
partials: map[string]string{"_header": "You are {{.role}}", "_footer": "End with {{.conclusion}}", "_unused": "This has {{.unused_var}}"},
expected: []string{"role", "conclusion", "username"}, // should NOT include unused_var
description: "Template with multiple partials",
shouldError: false,
},
{
name: "duplicate arguments",
content: "{{/* Duplicate arguments */}}\n{{.user}} said hello to {{.user}} again",
partials: map[string]string{},
expected: []string{"user"},
description: "Duplicate arguments",
shouldError: false,
},
{
name: "cyclic partial references",
content: "{{/* Template with cyclic partials */}}\n{{template \"_a\" .}}\nMain content: {{.main}}",
partials: map[string]string{
"_a": "Partial A with {{.a_var}} {{template \"_b\" .}}",
"_b": "Partial B with {{.b_var}} {{template \"_c\" .}}",
"_c": "Partial C with {{.c_var}} {{template \"_a\" .}}", // Creates a cycle: a -> b -> c -> a
},
expected: nil,
description: "Template with cyclic partials",
shouldError: true,
},
{
name: "template with or condition",
content: "{{/* Template with or condition */}}\n{{if or .show_message .show_alert}}Message: {{.message}}{{end}}\nAlways: {{.name}}",
partials: map[string]string{},
expected: []string{"show_message", "show_alert", "message", "name"},
description: "Template with or condition",
shouldError: false,
},
{
name: "template with variables",
content: "{{/* Template with variables */}}\n{{$name := .user_name}}{{$email := .user_email}}User: {{$name}} ({{$email}}) - Role: {{.role}}",
partials: map[string]string{},
expected: []string{"user_name", "user_email", "role"},
description: "Template with variables",
shouldError: false,
},
{
name: "template with range node",
content: "{{/* Template with range */}}\n{{range .items}}Item: {{.name}} - {{.value}}{{end}}\nTotal: {{.total}}",
partials: map[string]string{},
expected: []string{"items", "name", "value", "total"},
description: "Template with range",
shouldError: false,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
// Create a temporary directory for this test
testDir := filepath.Join(s.tempDir, tt.name)
err := os.MkdirAll(testDir, 0755)
require.NoError(s.T(), err, "Failed to create test directory")
// Write the main template file
testFile := filepath.Join(testDir, tt.name+".tmpl")
err = os.WriteFile(testFile, []byte(tt.content), 0644)
require.NoError(s.T(), err, "Failed to write test file")
// Write partial files
for partialName, partialContent := range tt.partials {
partialFile := filepath.Join(testDir, partialName+".tmpl")
err = os.WriteFile(partialFile, []byte(partialContent), 0644)
require.NoError(s.T(), err, "Failed to write partial file")
}
// Parse all templates in the test directory
tmpl, err := s.parser.ParseDir(testDir)
require.NoError(s.T(), err, "Failed to parse templates")
got, err := s.parser.ExtractPromptArgumentsFromTemplate(tmpl, tt.name)
if tt.shouldError {
assert.Error(s.T(), err, "ExtractPromptArgumentsFromTemplate() expected error, but got none")
return
}
require.NoError(s.T(), err, "ExtractPromptArgumentsFromTemplate() unexpected error")
// Sort both slices for consistent comparison
sort.Strings(got)
sort.Strings(tt.expected)
assert.Equal(s.T(), tt.expected, got, "ExtractPromptArgumentsFromTemplate() returned unexpected arguments")
})
}
}
// TestExtractPromptDescriptionFromFile tests description extraction from template comments
func (s *PromptsParserTestSuite) TestExtractPromptDescriptionFromFile() {
tests := []struct {
name string
content string
expectedDescription string
}{
{
name: "valid template with description",
content: "{{/* Template description */}}",
expectedDescription: "Template description",
},
{
name: "valid template with description, comment starts with dash",
content: "{{- /* Template description */}}",
expectedDescription: "Template description",
},
{
name: "valid template with description, comment ends with dash",
content: "{{/* Template description */ -}}",
expectedDescription: "Template description",
},
{
name: "valid template with description, comment starts and ends with dash",
content: "{{- /* Template description */ -}}",
expectedDescription: "Template description",
},
{
name: "template without description",
content: "Hello {{.name}}",
expectedDescription: "",
},
{
name: "template with valid comment and trim",
content: "{{/* Comment */}}",
expectedDescription: "Comment",
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
testFile := filepath.Join(s.tempDir, tt.name+".tmpl")
err := os.WriteFile(testFile, []byte(tt.content), 0644)
require.NoError(s.T(), err, "Failed to write test file")
description, err := s.parser.ExtractPromptDescriptionFromFile(testFile)
require.NoError(s.T(), err, "ExtractPromptDescriptionFromFile() unexpected error")
assert.Equal(s.T(), tt.expectedDescription, description, "ExtractPromptDescriptionFromFile() returned unexpected description")
})
}
}
// TestExtractPromptDescriptionFromFileErrorCases tests error cases for description extraction
func (s *PromptsParserTestSuite) TestExtractPromptDescriptionFromFileErrorCases() {
// Test non-existent file
_, err := s.parser.ExtractPromptDescriptionFromFile("/non/existent/file.tmpl")
assert.Error(s.T(), err, "ExtractPromptDescriptionFromFile() expected error for non-existent file, but got none")
}
// TestExtractPromptArgumentsFromTemplateErrorCases tests error cases for argument extraction
func (s *PromptsParserTestSuite) TestExtractPromptArgumentsFromTemplateErrorCases() {
// Create a valid template file so ParseDir doesn't fail
testFile := filepath.Join(s.tempDir, "test.tmpl")
err := os.WriteFile(testFile, []byte("{{/* Test */}}\nHello {{.name}}"), 0644)
require.NoError(s.T(), err, "Failed to write test file")
// Test non-existent template
tmpl, err := s.parser.ParseDir(s.tempDir)
require.NoError(s.T(), err, "Failed to parse templates")
_, err = s.parser.ExtractPromptArgumentsFromTemplate(tmpl, "non_existent_template")
assert.Error(s.T(), err, "ExtractPromptArgumentsFromTemplate() expected error for non-existent template, but got none")
}
// TestParseDirErrorCases tests error cases for template parsing
func (s *PromptsParserTestSuite) TestParseDirErrorCases() {
// Test non-existent directory
_, err := s.parser.ParseDir("/non/existent/directory")
assert.Error(s.T(), err, "ParseDir() expected error for non-existent directory, but got none")
// Test directory with invalid template syntax
invalidFile := filepath.Join(s.tempDir, "invalid.tmpl")
err = os.WriteFile(invalidFile, []byte("{{/* Invalid template */}}\n{{.unclosed"), 0644)
require.NoError(s.T(), err, "Failed to write invalid template file")
_, err = s.parser.ParseDir(s.tempDir)
assert.Error(s.T(), err, "ParseDir() expected error for invalid template syntax, but got none")
}
// TestWalkNodesNilHandling tests nil node handling in walkNodes
func (s *PromptsParserTestSuite) TestWalkNodesNilHandling() {
argsMap := make(map[string]struct{})
builtInFields := map[string]struct{}{"date": {}}
processedTemplates := make(map[string]bool)
// This should return nil immediately for nil node
err := s.parser.walkNodes(nil, argsMap, builtInFields, nil, processedTemplates, []string{})
assert.NoError(s.T(), err, "walkNodes() with nil node should return nil")
// argsMap should remain empty
assert.Empty(s.T(), argsMap, "walkNodes() with nil node should not modify argsMap")
}
// TestWalkNodesVariableHandling tests variable node handling in walkNodes
func (s *PromptsParserTestSuite) TestWalkNodesVariableHandling() {
// Create a template with a variable (non-$ variable)
testFile := filepath.Join(s.tempDir, "test.tmpl")
err := os.WriteFile(testFile, []byte("{{/* Test template */}}\n{{$var := .input}}{{$var}}"), 0644)
require.NoError(s.T(), err, "Failed to write test file")
tmpl, err := s.parser.ParseDir(s.tempDir)
require.NoError(s.T(), err, "Failed to parse templates")
// Test extracting arguments - should handle variable nodes properly
args, err := s.parser.ExtractPromptArgumentsFromTemplate(tmpl, "test")
require.NoError(s.T(), err, "ExtractPromptArgumentsFromTemplate() unexpected error")
// Should only contain "input", not the template variables
expected := []string{"input"}
assert.Equal(s.T(), expected, args, "ExtractPromptArgumentsFromTemplate() should only return template data arguments, not dollar variables")
}
// TestDict tests the dict helper function
func (s *PromptsParserTestSuite) TestDict() {
tests := []struct {
name string
args []string
expected map[string]interface{}
hasError bool
}{
{
name: "empty args",
args: []string{},
expected: map[string]interface{}{},
hasError: false,
},
{
name: "single key-value pair",
args: []string{"key", "value"},
expected: map[string]interface{}{"key": "value"},
hasError: false,
},
{
name: "multiple key-value pairs",
args: []string{"key1", "value1", "key2", "value2"},
expected: map[string]interface{}{"key1": "value1", "key2": "value2"},
hasError: false,
},
{
name: "odd number of arguments",
args: []string{"key1", "value1", "key2"},
expected: nil,
hasError: true,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
// Convert string slice to interface slice
args := make([]interface{}, len(tt.args))
for i, v := range tt.args {
args[i] = v
}
result := dict(args...)
if tt.hasError {
assert.Nil(s.T(), result, "dict() expected nil result for error case")
return
}
assert.NotNil(s.T(), result, "dict() unexpected nil result")
assert.Equal(s.T(), tt.expected, result, "dict() returned unexpected result")
})
}
// Test non-string key
s.Run("non-string key", func() {
result := dict(123, "value")
assert.Nil(s.T(), result, "dict() expected nil result for non-string key")
})
}
```
--------------------------------------------------------------------------------
/main_test.go:
--------------------------------------------------------------------------------
```go
package main
import (
"bytes"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type MainTestSuite struct {
suite.Suite
tempDir string
}
func TestMainTestSuite(t *testing.T) {
suite.Run(t, new(MainTestSuite))
}
func (s *MainTestSuite) SetupTest() {
s.tempDir = s.T().TempDir()
}
// TestRenderTemplateErrorCases tests error cases for template rendering
func (s *MainTestSuite) TestRenderTemplateErrorCases() {
var buf bytes.Buffer
// Test non-existent directory
err := renderTemplate(&buf, "/non/existent/directory", "template_name", nil, true)
assert.Error(s.T(), err, "renderTemplate() expected error for non-existent directory")
// Test template execution error with missing template
testFile := s.tempDir + "/error.tmpl"
// Create a template that will cause execution error (missing template reference)
err = os.WriteFile(testFile, []byte("{{/* Error template */}}\n{{template \"missing_template\" .}}"), 0644)
require.NoError(s.T(), err, "Failed to write test file")
var errorBuf bytes.Buffer
err = renderTemplate(&errorBuf, s.tempDir, "error", nil, true)
assert.Error(s.T(), err, "renderTemplate() expected execution error for missing template")
// Test error with non-existent template in renderTemplate
var nonExistentBuf bytes.Buffer
err = renderTemplate(&nonExistentBuf, s.tempDir, "does_not_exist", nil, true)
assert.Error(s.T(), err, "renderTemplate() expected error for non-existent template")
}
// TestRenderTemplate tests template rendering with environment variables and CLI arguments
func (s *MainTestSuite) TestRenderTemplate() {
tests := []struct {
name string
templateName string
cliArgs map[string]string
envVars map[string]string
enableJSONArgs bool
expectedOutput string
shouldError bool
}{
{
name: "greeting template, no vars set",
templateName: "greeting",
enableJSONArgs: true,
expectedOutput: "Hello <no value>!\nHave a great day!",
shouldError: false,
},
{
name: "greeting template with env var",
templateName: "greeting",
envVars: map[string]string{
"NAME": "John",
},
enableJSONArgs: true,
expectedOutput: "Hello John!\nHave a great day!",
shouldError: false,
},
{
name: "greeting template with CLI arg",
templateName: "greeting",
cliArgs: map[string]string{
"name": "Alice",
},
enableJSONArgs: true,
expectedOutput: "Hello Alice!\nHave a great day!",
shouldError: false,
},
{
name: "CLI args override env vars",
templateName: "greeting",
cliArgs: map[string]string{
"name": "CLI_User",
},
envVars: map[string]string{
"NAME": "ENV_User",
},
enableJSONArgs: true,
expectedOutput: "Hello CLI_User!\nHave a great day!",
shouldError: false,
},
{
name: "template with partials, some env vars not set",
templateName: "multiple_partials",
envVars: map[string]string{
"TITLE": "Test Document",
"NAME": "Bob",
"VERSION": "1.0.0",
},
enableJSONArgs: true,
expectedOutput: "# Test Document\nCreated by: <no value>\n## Description\n<no value>\n## Details\nThis is a test template with multiple partials.\nHello Bob!\nVersion: 1.0.0",
shouldError: false,
},
{
name: "template with partials, all env vars set",
templateName: "multiple_partials",
envVars: map[string]string{
"TITLE": "Test Document",
"AUTHOR": "Test Author",
"NAME": "Bob",
"DESCRIPTION": "This is a test description",
"VERSION": "1.0.0",
},
expectedOutput: "# Test Document\nCreated by: Test Author\n## Description\nThis is a test description\n## Details\nThis is a test template with multiple partials.\nHello Bob!\nVersion: 1.0.0",
enableJSONArgs: true,
shouldError: false,
},
{
name: "conditional greeting with env vars, show extra message true",
templateName: "conditional_greeting",
envVars: map[string]string{
"NAME": "Alice",
"SHOW_EXTRA_MESSAGE": "true",
},
expectedOutput: "Hello Alice!\nThis is an extra message just for you.\nHave a good day.",
enableJSONArgs: true,
shouldError: false,
},
{
name: "conditional greeting with env vars, show extra message false",
templateName: "conditional_greeting",
envVars: map[string]string{
"NAME": "Bob",
"SHOW_EXTRA_MESSAGE": "",
},
expectedOutput: "Hello Bob!\nHave a good day.",
enableJSONArgs: true,
shouldError: false,
},
{
name: "conditional greeting with multiple CLI args",
templateName: "conditional_greeting",
cliArgs: map[string]string{
"name": "Bob",
"show_extra_message": "true",
},
expectedOutput: "Hello Bob!\nThis is an extra message just for you.\nHave a good day.",
enableJSONArgs: true,
shouldError: false,
},
{
name: "mix CLI args and env vars",
templateName: "conditional_greeting",
cliArgs: map[string]string{
"name": "Charlie",
},
envVars: map[string]string{
"SHOW_EXTRA_MESSAGE": "true",
},
expectedOutput: "Hello Charlie!\nThis is an extra message just for you.\nHave a good day.",
enableJSONArgs: true,
shouldError: false,
},
{
name: "CLI arg with empty value",
templateName: "greeting",
cliArgs: map[string]string{
"name": "",
},
expectedOutput: "Hello !\nHave a great day!",
enableJSONArgs: true,
shouldError: false,
},
{
name: "template with logical operators (and/or)",
templateName: "logical_operators",
envVars: map[string]string{
"IS_ADMIN": "true",
"HAS_PERMISSION": "true",
"RESOURCE": "server logs",
"SHOW_WARNING": "",
"SHOW_ERROR": "true",
"MESSAGE": "System maintenance scheduled",
"IS_PREMIUM": "true",
"IS_TRIAL": "",
"FEATURE_ENABLED": "true",
"FEATURE_NAME": "Advanced Analytics",
"USERNAME": "admin_user",
},
expectedOutput: "Admin Access: You have full access to server logs.\nAlert: System maintenance scheduled\nPremium Feature: Advanced Analytics is available.\nUser: admin_user",
enableJSONArgs: true,
shouldError: false,
},
{
name: "JSON parsing enabled - boolean true",
templateName: "conditional_greeting",
cliArgs: map[string]string{
"name": "JSONUser",
"show_extra_message": "true",
},
expectedOutput: "Hello JSONUser!\nThis is an extra message just for you.\nHave a good day.",
enableJSONArgs: true,
shouldError: false,
},
{
name: "JSON parsing enabled - boolean false",
templateName: "conditional_greeting",
cliArgs: map[string]string{
"name": "JSONUser",
"show_extra_message": "false",
},
expectedOutput: "Hello JSONUser!\nHave a good day.",
enableJSONArgs: true,
shouldError: false,
},
{
name: "JSON parsing disabled - boolean string remains string",
templateName: "conditional_greeting",
cliArgs: map[string]string{
"name": "StringUser",
"show_extra_message": "true",
},
enableJSONArgs: false,
expectedOutput: "Hello StringUser!\nThis is an extra message just for you.\nHave a good day.",
shouldError: false,
},
{
name: "JSON parsing disabled - false string treated as truthy",
templateName: "conditional_greeting",
cliArgs: map[string]string{
"name": "StringUser",
"show_extra_message": "false",
},
enableJSONArgs: false,
expectedOutput: "Hello StringUser!\nThis is an extra message just for you.\nHave a good day.",
shouldError: false,
},
{
name: "non-existent template",
templateName: "non_existent_template",
enableJSONArgs: true,
expectedOutput: "",
shouldError: true,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
// Save original environment
originalEnv := make(map[string]string)
for k := range tt.envVars {
if v, ok := os.LookupEnv(k); ok {
originalEnv[k] = v
}
}
defer func() {
for k := range tt.envVars {
if v, ok := originalEnv[k]; ok {
_ = os.Setenv(k, v)
} else {
_ = os.Unsetenv(k)
}
}
}()
// Set test environment variables
for k, v := range tt.envVars {
_ = os.Setenv(k, v)
}
var buf bytes.Buffer
err := renderTemplate(&buf, "./testdata", tt.templateName, tt.cliArgs, tt.enableJSONArgs)
if tt.shouldError {
assert.Error(s.T(), err, "expected error but got none")
} else {
require.NoError(s.T(), err, "unexpected error")
}
output := normalizeNewlines(buf.String())
assert.Equal(s.T(), tt.expectedOutput, output, "unexpected output")
})
}
}
// normalizeNewlines is a helper function to normalize newlines in strings
func normalizeNewlines(s string) string {
// Replace multiple consecutive newlines with single newlines
for strings.Contains(s, "\n\n") {
s = strings.ReplaceAll(s, "\n\n", "\n")
}
return strings.TrimSpace(s)
}
// removeANSIColors removes ANSI color escape sequences from a string
func removeANSIColors(s string) string {
ansiRegex := regexp.MustCompile(`\x1b\[[0-9;]*m`)
return ansiRegex.ReplaceAllString(s, "")
}
// TestListTemplates tests the listTemplates function
func (s *MainTestSuite) TestListTemplates() {
tests := []struct {
name string
detailed bool
expectedLines []string
shouldError bool
}{
{
name: "list templates basic mode",
detailed: false,
expectedLines: []string{
templateText("conditional_greeting.tmpl"),
templateText("greeting.tmpl"),
templateText("greeting_with_partials.tmpl"),
templateText("logical_operators.tmpl"),
templateText("multiple_partials.tmpl"),
templateText("range_scalars.tmpl"),
templateText("range_structs.tmpl"),
templateText("with_object.tmpl"),
},
shouldError: false,
},
{
name: "list templates verbose mode",
detailed: true,
expectedLines: []string{
templateText("conditional_greeting.tmpl"),
" Description: Conditional greeting template",
" Variables: name, show_extra_message",
templateText("greeting.tmpl"),
" Description: Greeting standalone template with no partials",
" Variables: name",
templateText("greeting_with_partials.tmpl"),
" Description: Greeting template with partial",
" Variables: name",
templateText("logical_operators.tmpl"),
" Description: Template with logical operators (and/or) in if blocks",
" Variables: feature_enabled, feature_name, has_permission, is_admin, is_premium, is_trial, message, resource, show_error, show_warning, username",
templateText("multiple_partials.tmpl"),
" Description: Template with multiple partials",
" Variables: author, description, name, title, version",
templateText("range_scalars.tmpl"),
" Description: Template for testing range with JSON array of scalars",
" Variables: numbers, result, tags",
templateText("range_structs.tmpl"),
" Description: Template for testing range with JSON array of structs",
" Variables: age, name, role, total, users",
templateText("with_object.tmpl"),
" Description: Template for testing with + JSON object",
" Variables: config, debug, environment, name, version",
},
shouldError: false,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
var buf bytes.Buffer
err := listTemplates(&buf, "./testdata", tt.detailed)
if tt.shouldError {
assert.Error(s.T(), err, "expected error but got none")
} else {
require.NoError(s.T(), err, "unexpected error")
}
output := buf.String()
lines := strings.Split(strings.TrimSpace(output), "\n")
// For basic mode, check exact match
if !tt.detailed {
assert.Equal(s.T(), len(tt.expectedLines), len(lines), "number of lines should match")
for i, expectedLine := range tt.expectedLines {
if i < len(lines) {
assert.Equal(s.T(), expectedLine, lines[i], "line %d should match", i)
}
}
return
}
// For detailed mode, check exact match including variables
lineIndex := 0
for _, expectedLine := range tt.expectedLines {
if lineIndex >= len(lines) {
s.T().Fatalf("Not enough lines in output. Expected at least %d, got %d", len(tt.expectedLines), len(lines))
}
if strings.HasPrefix(expectedLine, " Variables: ") {
// Remove ANSI color codes from the actual line for comparison
actualLine := removeANSIColors(lines[lineIndex])
assert.Equal(s.T(), expectedLine, actualLine, "line %d should match (variables are now sorted)", lineIndex)
} else {
assert.Equal(s.T(), expectedLine, lines[lineIndex], "line %d should match", lineIndex)
}
lineIndex++
}
})
}
}
// TestListTemplatesErrorCases tests error cases for listTemplates
func (s *MainTestSuite) TestListTemplatesErrorCases() {
var buf bytes.Buffer
// Test non-existent directory
err := listTemplates(&buf, "/non/existent/directory", false)
assert.Error(s.T(), err, "listTemplates() expected error for non-existent directory")
// Test empty directory
emptyDir := s.T().TempDir()
var emptyBuf bytes.Buffer
err = listTemplates(&emptyBuf, emptyDir, true)
require.NoError(s.T(), err, "listTemplates() should not error for empty directory")
output := emptyBuf.String()
assert.Contains(s.T(), output, "No templates found", "should indicate no templates found")
emptyBuf.Reset()
err = listTemplates(&emptyBuf, emptyDir, false)
require.NoError(s.T(), err, "listTemplates() should not error for empty directory")
require.Empty(s.T(), emptyBuf.String())
}
// TestListTemplatesWithPartials tests that partials are excluded from listing
func (s *MainTestSuite) TestListTemplatesWithPartials() {
// Create a temp directory with templates and partials
tempDir := s.T().TempDir()
// Create regular template
err := os.WriteFile(tempDir+"/regular.tmpl", []byte("{{/* Regular template */}}\nHello!"), 0644)
require.NoError(s.T(), err)
// Create partial template (should be excluded)
err = os.WriteFile(tempDir+"/_partial.tmpl", []byte("{{/* Partial template */}}\nThis is a partial"), 0644)
require.NoError(s.T(), err)
var buf bytes.Buffer
err = listTemplates(&buf, tempDir, false)
require.NoError(s.T(), err)
output := buf.String()
assert.Contains(s.T(), output, "regular.tmpl", "should include regular template")
assert.NotContains(s.T(), output, "_partial.tmpl", "should exclude partial template")
}
// TestValidateTemplates tests the validateTemplates function
func (s *MainTestSuite) TestValidateTemplates() {
tests := []struct {
name string
templateName string
templates map[string]string
expectedOutput []string
shouldError bool
}{
{
name: "validate all valid templates",
templateName: "",
templates: map[string]string{
"valid1.tmpl": "{{/* Valid template 1 */}}\nHello {{.name}}!",
"valid2.tmpl": "{{/* Valid template 2 */}}\nWelcome {{.user}}!",
},
expectedOutput: []string{
"✓ valid1.tmpl - Valid",
"✓ valid2.tmpl - Valid",
},
shouldError: false,
},
{
name: "validate specific valid template",
templateName: "valid1.tmpl",
templates: map[string]string{
"valid1.tmpl": "{{/* Valid template 1 */}}\nHello {{.name}}!",
"valid2.tmpl": "{{/* Valid template 2 */}}\nWelcome {{.user}}!",
},
expectedOutput: []string{
"✓ valid1.tmpl - Valid",
},
shouldError: false,
},
{
name: "validate specific valid template without extension",
templateName: "valid1",
templates: map[string]string{
"valid1.tmpl": "{{/* Valid template 1 */}}\nHello {{.name}}!",
"valid2.tmpl": "{{/* Valid template 2 */}}\nWelcome {{.user}}!",
},
expectedOutput: []string{
"✓ valid1.tmpl - Valid",
},
shouldError: false,
},
{
name: "validate template with missing reference",
templateName: "",
templates: map[string]string{
"valid.tmpl": "{{/* Valid template */}}\nHello {{.name}}!",
"missing_ref.tmpl": "{{/* Template with missing reference */}}\n{{template \"nonexistent\" .}}",
},
expectedOutput: []string{
"✗ missing_ref.tmpl - Error:",
"✓ valid.tmpl - Valid",
},
shouldError: true,
},
{
name: "validate specific template with missing reference",
templateName: "missing_ref.tmpl",
templates: map[string]string{
"valid.tmpl": "{{/* Valid template */}}\nHello {{.name}}!",
"missing_ref.tmpl": "{{/* Template with missing reference */}}\n{{template \"nonexistent\" .}}",
},
expectedOutput: []string{
"✗ missing_ref.tmpl - Error:",
},
shouldError: true,
},
{
name: "validate template with partials",
templateName: "",
templates: map[string]string{
"main.tmpl": "{{/* Main template */}}\n{{template \"_partial\" .}}",
"_partial.tmpl": "{{/* Partial template */}}\nHello {{.name}}!",
},
expectedOutput: []string{
"✓ main.tmpl - Valid",
},
shouldError: false,
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
// Create temp directory and test templates
tempDir := s.T().TempDir()
for filename, content := range tt.templates {
err := os.WriteFile(filepath.Join(tempDir, filename), []byte(content), 0644)
require.NoError(s.T(), err)
}
// Run validateTemplates and capture output from buffer
var buf bytes.Buffer
err := validateTemplates(&buf, tempDir, tt.templateName)
if tt.shouldError {
assert.Error(s.T(), err, "expected error but got none")
} else {
require.NoError(s.T(), err, "unexpected error")
}
output := buf.String()
lines := strings.Split(strings.TrimSpace(output), "\n")
// Check that all expected output lines are present
for _, expectedLine := range tt.expectedOutput {
found := false
for _, line := range lines {
// Remove ANSI color codes for comparison
cleanLine := removeANSIColors(line)
cleanExpected := removeANSIColors(expectedLine)
if strings.Contains(cleanLine, cleanExpected) ||
(strings.Contains(cleanExpected, "Error:") && strings.Contains(cleanLine, "Error:")) {
found = true
break
}
}
assert.True(s.T(), found, "expected line '%s' not found in output: %s", expectedLine, output)
}
})
}
}
// TestValidateTemplatesErrorCases tests error cases for validateTemplates
func (s *MainTestSuite) TestValidateTemplatesErrorCases() {
tests := []struct {
name string
promptsDir string
templateName string
setupFunc func(string) error
expectedError string
}{
{
name: "non-existent directory",
promptsDir: "/non/existent/directory",
templateName: "",
expectedError: "read prompts directory",
},
{
name: "non-existent specific template",
promptsDir: "",
templateName: "does_not_exist.tmpl",
setupFunc: func(dir string) error {
return os.WriteFile(filepath.Join(dir, "exists.tmpl"), []byte("{{/* Exists */}}\nHello!"), 0644)
},
expectedError: "not found",
},
{
name: "non-existent specific template without extension",
promptsDir: "",
templateName: "does_not_exist",
setupFunc: func(dir string) error {
return os.WriteFile(filepath.Join(dir, "exists.tmpl"), []byte("{{/* Exists */}}\nHello!"), 0644)
},
expectedError: "not found",
},
{
name: "empty directory",
promptsDir: "",
setupFunc: func(dir string) error { return nil },
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
var tempDir string
if tt.promptsDir == "" {
tempDir = s.T().TempDir()
if tt.setupFunc != nil {
err := tt.setupFunc(tempDir)
require.NoError(s.T(), err)
}
} else {
tempDir = tt.promptsDir
}
var buf bytes.Buffer
err := validateTemplates(&buf, tempDir, tt.templateName)
if tt.expectedError != "" {
assert.Error(s.T(), err)
assert.Contains(s.T(), err.Error(), tt.expectedError)
} else {
// For empty directory case, should not error but output warning
require.NoError(s.T(), err)
output := buf.String()
assert.Contains(s.T(), output, "No templates found")
}
})
}
}
// TestValidateTemplatesOutput tests the output formatting of validateTemplates
func (s *MainTestSuite) TestValidateTemplatesOutput() {
// Test with syntax error that occurs during parsing
tempDir := s.T().TempDir()
// Invalid template with syntax error
err := os.WriteFile(filepath.Join(tempDir, "invalid.tmpl"),
[]byte("{{/* Invalid template */}}\nHello {{.name}"), 0644)
require.NoError(s.T(), err)
var buf bytes.Buffer
err = validateTemplates(&buf, tempDir, "")
assert.Error(s.T(), err)
assert.Contains(s.T(), err.Error(), "parse prompts directory")
// Test with valid templates to verify successful output formatting
tempDir2 := s.T().TempDir()
// Valid template
err = os.WriteFile(filepath.Join(tempDir2, "valid.tmpl"),
[]byte("{{/* Valid template */}}\nHello {{.name}}!"), 0644)
require.NoError(s.T(), err)
// Run validateTemplates and capture output from buffer
var buf2 bytes.Buffer
err = validateTemplates(&buf2, tempDir2, "")
require.NoError(s.T(), err)
output := buf2.String()
cleanOutput := removeANSIColors(output)
// Check that output contains the template
assert.Contains(s.T(), cleanOutput, "valid.tmpl")
// Check formatting - should contain success icon
assert.Contains(s.T(), cleanOutput, "✓") // Success icon
// Check status message
assert.Contains(s.T(), cleanOutput, "Valid")
}
```
--------------------------------------------------------------------------------
/prompts_server_test.go:
--------------------------------------------------------------------------------
```go
package main
import (
"bytes"
"context"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"github.com/mark3labs/mcp-go/client"
"github.com/mark3labs/mcp-go/client/transport"
"github.com/mark3labs/mcp-go/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type PromptsServerTestSuite struct {
suite.Suite
tempDir string
logger *slog.Logger
}
func TestTestSuite(t *testing.T) {
suite.Run(t, new(PromptsServerTestSuite))
}
func (s *PromptsServerTestSuite) SetupTest() {
s.tempDir = s.T().TempDir()
s.logger = slog.New(slog.DiscardHandler)
}
// TestServeStdio tests comprehensive server integration with prompts using ServeStdio
func (s *PromptsServerTestSuite) TestServeStdio() {
ctx := context.Background()
tests := []struct {
name string
enableJSONArgs bool
promptName string
arguments map[string]string
expectedContent string // If empty, only basic validation is performed
description string
}{
{
name: "BasicFunctionality",
enableJSONArgs: false,
promptName: "greeting",
arguments: map[string]string{"name": "John"},
expectedContent: "Hello John!\nHave a great day!",
description: "Test basic functionality without JSON argument parsing",
},
{
name: "WithJSONArgumentParsing",
enableJSONArgs: true,
promptName: "conditional_greeting",
arguments: map[string]string{
"name": "Alice",
"show_extra_message": "false", // JSON boolean becomes actual boolean
},
expectedContent: "Hello Alice!\nHave a good day.",
description: "Test JSON boolean parsing - 'false' becomes boolean false",
},
{
name: "WithDisabledJSONArgumentParsing",
enableJSONArgs: false,
promptName: "conditional_greeting",
arguments: map[string]string{
"name": "Bob",
"show_extra_message": "false", // Remains string "false" (truthy!)
},
expectedContent: "Hello Bob!\nThis is an extra message just for you.\nHave a good day.",
description: "Test disabled JSON parsing - 'false' string is truthy",
},
// All testdata prompts with JSON parsing enabled (exact content validation)
{
name: "greeting",
enableJSONArgs: true,
promptName: "greeting",
arguments: map[string]string{"name": "TestUser"},
description: "Test greeting template",
expectedContent: "Hello TestUser!\nHave a great day!",
},
{
name: "conditional_greeting",
enableJSONArgs: true,
promptName: "conditional_greeting",
arguments: map[string]string{"name": "TestUser", "show_extra_message": "true"},
description: "Test conditional greeting template",
expectedContent: "Hello TestUser!\nThis is an extra message just for you.\nHave a good day.",
},
{
name: "greeting_with_partials",
enableJSONArgs: true,
promptName: "greeting_with_partials",
arguments: map[string]string{"name": "TestUser"},
description: "Test greeting template with partials",
expectedContent: "Hello TestUser!\nWelcome to the system.\nHave a great day!",
},
{
name: "logical_operators",
enableJSONArgs: true,
promptName: "logical_operators",
arguments: map[string]string{
"is_admin": "true",
"has_permission": "true",
"resource": "admin_panel",
"show_warning": "true",
"show_error": "false",
"message": "System maintenance in progress",
"is_premium": "true",
"is_trial": "false",
"feature_enabled": "true",
"feature_name": "Advanced Analytics",
"username": "TestUser",
},
description: "Test template with logical operators",
expectedContent: "Admin Access: You have full access to admin_panel.\nAlert: System maintenance in progress\nPremium Feature: Advanced Analytics is available.\nUser: TestUser",
},
{
name: "multiple_partials",
enableJSONArgs: true,
promptName: "multiple_partials",
arguments: map[string]string{
"name": "TestUser",
"title": "Test Title",
"author": "Test Author",
"description": "This is a test description for the template",
"version": "v1.0.0",
},
description: "Test template with multiple partials",
expectedContent: "# Test Title\nCreated by: Test Author\n## Description\nThis is a test description for the template\n## Details\nThis is a test template with multiple partials.\nHello TestUser!\nVersion: v1.0.0",
},
{
name: "range_scalars",
enableJSONArgs: true,
promptName: "range_scalars",
arguments: map[string]string{
"numbers": `[1, 2, 3, 4, 5]`,
"tags": `["go", "template", "test"]`,
"result": "success",
},
description: "Test template with range over scalars",
expectedContent: "Numbers: 1 2 3 4 5 \nTags: #go #template #test \nResult: success",
},
{
name: "range_structs",
enableJSONArgs: true,
promptName: "range_structs",
arguments: map[string]string{
"users": `[{"name": "Alice", "age": 30, "role": "admin"}, {"name": "Bob", "age": 25, "role": "user"}]`,
"total": "2",
},
description: "Test template with range over structs",
expectedContent: "Users:\n - Alice (30) - admin\n - Bob (25) - user\nTotal: 2 users",
},
{
name: "with_object",
enableJSONArgs: true,
promptName: "with_object",
arguments: map[string]string{
"config": `{"name": "MyApp", "version": "1.2.3", "debug": true}`,
"environment": "development",
},
description: "Test template with object argument",
expectedContent: "Configuration:\n Name: MyApp\n Version: 1.2.3\n Debug: true\nEnvironment: development",
},
}
for _, tc := range tests {
s.Run(tc.name, func() {
// Create prompts server that will watch ./testdata directory
_, mcpClient, promptsClose := s.makePromptsServerAndClient(ctx, "./testdata", tc.enableJSONArgs)
defer promptsClose()
// List all available prompts to verify prompt exists
listResult, err := mcpClient.ListPrompts(ctx, mcp.ListPromptsRequest{})
require.NoError(s.T(), err, "ListPrompts failed for %s", tc.name)
// Verify prompt exists in list
var foundPrompt *mcp.Prompt
for _, prompt := range listResult.Prompts {
if prompt.Name == tc.promptName {
foundPrompt = &prompt
break
}
}
require.NotNil(s.T(), foundPrompt, "Prompt %s not found in list", tc.promptName)
// Test GetPrompt with specified arguments
var getReq mcp.GetPromptRequest
getReq.Params.Name = tc.promptName
getReq.Params.Arguments = tc.arguments
getResult, err := mcpClient.GetPrompt(ctx, getReq)
require.NoError(s.T(), err, "GetPrompt failed for %s", tc.name)
// Verify basic response structure
assert.NotEmpty(s.T(), getResult.Description, "Expected non-empty description for %s", tc.name)
require.Len(s.T(), getResult.Messages, 1, "Expected exactly 1 message for %s", tc.name)
content, ok := getResult.Messages[0].Content.(mcp.TextContent)
require.True(s.T(), ok, "Expected TextContent for %s", tc.name)
assert.NotEmpty(s.T(), content.Text, "Expected non-empty content for %s", tc.name)
actualContent := normalizeNewlines(content.Text)
assert.Equal(s.T(), tc.expectedContent, actualContent, "Unexpected content for %s: %s", tc.name, tc.description)
})
}
}
// TestParseMCPArgs tests parseMCPArgs function functionality
func (s *PromptsServerTestSuite) TestParseMCPArgs() {
tests := []struct {
name string
input map[string]string
enableJSONArgs bool
expected map[string]interface{}
}{
{
name: "empty arguments with JSON enabled",
input: map[string]string{},
enableJSONArgs: true,
expected: map[string]interface{}{},
},
{
name: "string arguments remain strings with JSON enabled",
input: map[string]string{
"name": "John",
"message": "Hello World",
},
enableJSONArgs: true,
expected: map[string]interface{}{
"name": "John",
"message": "Hello World",
},
},
{
name: "boolean arguments become booleans with JSON enabled",
input: map[string]string{
"enabled": "true",
"disabled": "false",
},
enableJSONArgs: true,
expected: map[string]interface{}{
"enabled": true,
"disabled": false,
},
},
{
name: "number arguments become numbers with JSON enabled",
input: map[string]string{
"count": "42",
"price": "19.99",
"balance": "-100.5",
},
enableJSONArgs: true,
expected: map[string]interface{}{
"count": float64(42),
"price": 19.99,
"balance": -100.5,
},
},
{
name: "null argument becomes nil with JSON enabled",
input: map[string]string{
"optional": "null",
},
enableJSONArgs: true,
expected: map[string]interface{}{
"optional": nil,
},
},
{
name: "array arguments become arrays with JSON enabled",
input: map[string]string{
"items": `["apple", "banana", "cherry"]`,
"numbers": `[1, 2, 3]`,
},
enableJSONArgs: true,
expected: map[string]interface{}{
"items": []interface{}{"apple", "banana", "cherry"},
"numbers": []interface{}{float64(1), float64(2), float64(3)},
},
},
{
name: "object arguments become objects with JSON enabled",
input: map[string]string{
"user": `{"name": "Alice", "age": 30, "active": true}`,
},
enableJSONArgs: true,
expected: map[string]interface{}{
"user": map[string]interface{}{
"name": "Alice",
"age": float64(30),
"active": true,
},
},
},
{
name: "invalid JSON remains as strings with JSON enabled",
input: map[string]string{
"invalid_json": `{name: "Alice"}`, // Missing quotes around key
"incomplete": `{"name": "Alice"`, // Missing closing brace
},
enableJSONArgs: true,
expected: map[string]interface{}{
"invalid_json": `{name: "Alice"}`,
"incomplete": `{"name": "Alice"`,
},
},
{
name: "all arguments remain strings when JSON disabled",
input: map[string]string{
"name": "John",
"enabled": "true",
"count": "42",
"optional": "null",
"items": `["a", "b"]`,
},
enableJSONArgs: false,
expected: map[string]interface{}{
"name": "John",
"enabled": "true",
"count": "42",
"optional": "null",
"items": `["a", "b"]`,
},
},
}
for _, tt := range tests {
s.Run(tt.name, func() {
data := make(map[string]interface{})
parseMCPArgs(tt.input, tt.enableJSONArgs, data)
assert.Equal(s.T(), tt.expected, data, "parseMCPArgs() returned unexpected result")
})
}
}
// TestReloadPromptsNewPromptAdded tests reloadPrompts method with new prompts via ServeStdio
func (s *PromptsServerTestSuite) TestReloadPromptsNewPromptAdded() {
ctx := context.Background()
// Create initial prompt file so ParseDir doesn't fail
initialPromptFile := filepath.Join(s.tempDir, "initial_prompt.tmpl")
initialPromptContent := `{{/* Initial test prompt */}}
Hello {{.name}}! This is the initial prompt.`
err := os.WriteFile(initialPromptFile, []byte(initialPromptContent), 0644)
require.NoError(s.T(), err, "Failed to write initial prompt file")
// Create prompts server that will watch the temp directory
_, mcpClient, promptsClose := s.makePromptsServerAndClient(ctx, s.tempDir, true)
defer promptsClose()
// Verify initial prompt exists
listResult, err := mcpClient.ListPrompts(ctx, mcp.ListPromptsRequest{})
require.NoError(s.T(), err, "ListPrompts failed")
require.Len(s.T(), listResult.Prompts, 1, "Expected 1 prompt initially")
assert.Equal(s.T(), "initial_prompt", listResult.Prompts[0].Name, "Unexpected initial prompt name")
// Create a new prompt file on filesystem
newPromptFile := filepath.Join(s.tempDir, "new_prompt.tmpl")
newPromptContent := `{{/* New test prompt */}}
Hello {{.name}}! This is a new prompt.`
err = os.WriteFile(newPromptFile, []byte(newPromptContent), 0644)
require.NoError(s.T(), err, "Failed to write new prompt file")
// Give the client-server communication time to process the changes
time.Sleep(100 * time.Millisecond)
// Client should now see both prompts
listResult, err = mcpClient.ListPrompts(ctx, mcp.ListPromptsRequest{})
require.NoError(s.T(), err, "ListPrompts failed after adding prompt")
require.Len(s.T(), listResult.Prompts, 2, "Expected 2 prompts after adding")
// Find the new prompt in the list
var newPrompt *mcp.Prompt
for _, prompt := range listResult.Prompts {
if prompt.Name == "new_prompt" {
newPrompt = &prompt
break
}
}
require.NotNil(s.T(), newPrompt, "New prompt not found in list")
assert.Equal(s.T(), "New test prompt", newPrompt.Description, "Unexpected prompt description")
// Verify the client can call the new prompt
getReq := mcp.GetPromptRequest{}
getReq.Params.Name = "new_prompt"
getReq.Params.Arguments = map[string]string{"name": "Alice"}
getResult, err := mcpClient.GetPrompt(ctx, getReq)
require.NoError(s.T(), err, "GetPrompt failed for new prompt")
require.Len(s.T(), getResult.Messages, 1, "Expected exactly 1 message")
content, ok := getResult.Messages[0].Content.(mcp.TextContent)
require.True(s.T(), ok, "Expected TextContent")
assert.Contains(s.T(), content.Text, "Hello Alice! This is a new prompt.", "Unexpected new prompt content")
}
// TestReloadPromptsPromptRemoved tests reloadPrompts method with prompt removal via ServeStdio
func (s *PromptsServerTestSuite) TestReloadPromptsPromptRemoved() {
ctx := context.Background()
// Create initial prompt file
promptFile := filepath.Join(s.tempDir, "test_prompt.tmpl")
promptContent := `{{/* Test prompt to be removed */}}
Hello {{.name}}!`
err := os.WriteFile(promptFile, []byte(promptContent), 0644)
require.NoError(s.T(), err, "Failed to write test prompt file")
// Create prompts server that will watch the temp directory
_, mcpClient, promptsClose := s.makePromptsServerAndClient(ctx, s.tempDir, true)
defer promptsClose()
// Verify prompt exists initially
listResult, err := mcpClient.ListPrompts(ctx, mcp.ListPromptsRequest{})
require.NoError(s.T(), err, "ListPrompts failed")
require.Len(s.T(), listResult.Prompts, 1, "Expected 1 prompt initially")
assert.Equal(s.T(), "test_prompt", listResult.Prompts[0].Name, "Unexpected prompt name")
// Verify client can call the prompt
getReq := mcp.GetPromptRequest{}
getReq.Params.Name = "test_prompt"
getReq.Params.Arguments = map[string]string{"name": "Bob"}
_, err = mcpClient.GetPrompt(ctx, getReq)
require.NoError(s.T(), err, "GetPrompt should work before removal")
// Create another prompt file to avoid the empty directory issue
anotherPromptFile := filepath.Join(s.tempDir, "another_prompt.tmpl")
anotherPromptContent := `{{/* Another prompt that will remain */}}
Greetings {{.name}}!`
err = os.WriteFile(anotherPromptFile, []byte(anotherPromptContent), 0644)
require.NoError(s.T(), err, "Failed to write another prompt file")
// Remove the original prompt file from filesystem
err = os.Remove(promptFile)
require.NoError(s.T(), err, "Failed to remove prompt file")
// Give the client-server communication time to process the changes
time.Sleep(100 * time.Millisecond)
// Client should now see only the remaining prompt
listResult, err = mcpClient.ListPrompts(ctx, mcp.ListPromptsRequest{})
require.NoError(s.T(), err, "ListPrompts failed after removal")
require.Len(s.T(), listResult.Prompts, 1, "Expected 1 prompt after removal")
assert.Equal(s.T(), "another_prompt", listResult.Prompts[0].Name, "Expected only another_prompt to remain")
// Client should get error when trying to call removed prompt
_, err = mcpClient.GetPrompt(ctx, getReq)
assert.Error(s.T(), err, "Expected error when getting removed prompt")
// But should be able to call the remaining prompt
getReq.Params.Name = "another_prompt"
_, err = mcpClient.GetPrompt(ctx, getReq)
require.NoError(s.T(), err, "Should be able to call remaining prompt")
}
// TestReloadPromptsArgumentAdded tests reloadPrompts method with argument changes via ServeStdio
func (s *PromptsServerTestSuite) TestReloadPromptsArgumentAdded() {
ctx := context.Background()
// Create initial prompt with one argument
promptFile := filepath.Join(s.tempDir, "evolving_prompt.tmpl")
initialContent := `{{/* Prompt that will gain an argument */}}
Hello {{.name}}!`
err := os.WriteFile(promptFile, []byte(initialContent), 0644)
require.NoError(s.T(), err, "Failed to write initial prompt file")
// Create prompts server that will watch the temp directory
_, mcpClient, promptsClose := s.makePromptsServerAndClient(ctx, s.tempDir, true)
defer promptsClose()
// Verify initial prompt has one argument
listResult, err := mcpClient.ListPrompts(ctx, mcp.ListPromptsRequest{})
require.NoError(s.T(), err, "ListPrompts failed")
require.Len(s.T(), listResult.Prompts, 1, "Expected 1 prompt initially")
require.Len(s.T(), listResult.Prompts[0].Arguments, 1, "Expected 1 argument initially")
assert.Equal(s.T(), "name", listResult.Prompts[0].Arguments[0].Name, "Expected 'name' argument")
// Update prompt file to add new argument
updatedContent := `{{/* Prompt that will gain an argument */}}
Hello {{.name}}! Your age is {{.age}}.`
err = os.WriteFile(promptFile, []byte(updatedContent), 0644)
require.NoError(s.T(), err, "Failed to update prompt file")
// Give the client-server communication time to process the changes
time.Sleep(100 * time.Millisecond)
// Client should now see the prompt with two arguments
listResult, err = mcpClient.ListPrompts(ctx, mcp.ListPromptsRequest{})
require.NoError(s.T(), err, "ListPrompts failed after argument addition")
require.Len(s.T(), listResult.Prompts, 1, "Expected 1 prompt after update")
require.Len(s.T(), listResult.Prompts[0].Arguments, 2, "Expected 2 arguments after update")
// Verify both arguments are present
argNames := make([]string, len(listResult.Prompts[0].Arguments))
for i, arg := range listResult.Prompts[0].Arguments {
argNames[i] = arg.Name
}
assert.Contains(s.T(), argNames, "name", "Expected 'name' argument")
assert.Contains(s.T(), argNames, "age", "Expected 'age' argument")
// Verify client can call the updated prompt with both arguments
getReq := mcp.GetPromptRequest{}
getReq.Params.Name = "evolving_prompt"
getReq.Params.Arguments = map[string]string{"name": "Alice", "age": "25"}
getResult, err := mcpClient.GetPrompt(ctx, getReq)
require.NoError(s.T(), err, "GetPrompt failed for updated prompt")
require.Len(s.T(), getResult.Messages, 1, "Expected exactly 1 message")
content, ok := getResult.Messages[0].Content.(mcp.TextContent)
require.True(s.T(), ok, "Expected TextContent")
assert.Contains(s.T(), content.Text, "Hello Alice! Your age is 25.", "Unexpected updated prompt content")
}
// TestReloadPromptsArgumentRemoved tests reloadPrompts method with argument removal via ServeStdio
func (s *PromptsServerTestSuite) TestReloadPromptsArgumentRemoved() {
ctx := context.Background()
// Create initial prompt with two arguments
promptFile := filepath.Join(s.tempDir, "shrinking_prompt.tmpl")
initialContent := `{{/* Prompt that will lose an argument */}}
Hello {{.name}}! Your age is {{.age}}.`
err := os.WriteFile(promptFile, []byte(initialContent), 0644)
require.NoError(s.T(), err, "Failed to write initial prompt file")
// Create prompts server that will watch the temp directory
_, mcpClient, promptsClose := s.makePromptsServerAndClient(ctx, s.tempDir, true)
defer promptsClose()
// Verify initial prompt has two arguments
listResult, err := mcpClient.ListPrompts(ctx, mcp.ListPromptsRequest{})
require.NoError(s.T(), err, "ListPrompts failed")
require.Len(s.T(), listResult.Prompts, 1, "Expected 1 prompt initially")
require.Len(s.T(), listResult.Prompts[0].Arguments, 2, "Expected 2 arguments initially")
// Update prompt file to remove age argument
updatedContent := `{{/* Prompt that will lose an argument */}}
Hello {{.name}}!`
err = os.WriteFile(promptFile, []byte(updatedContent), 0644)
require.NoError(s.T(), err, "Failed to update prompt file")
// Give the client-server communication time to process the changes
time.Sleep(100 * time.Millisecond)
// Client should now see the prompt with only one argument
listResult, err = mcpClient.ListPrompts(ctx, mcp.ListPromptsRequest{})
require.NoError(s.T(), err, "ListPrompts failed after argument removal")
require.Len(s.T(), listResult.Prompts, 1, "Expected 1 prompt after update")
require.Len(s.T(), listResult.Prompts[0].Arguments, 1, "Expected 1 argument after update")
assert.Equal(s.T(), "name", listResult.Prompts[0].Arguments[0].Name, "Expected only 'name' argument to remain")
// Verify client can call the updated prompt with only the remaining argument
getReq := mcp.GetPromptRequest{}
getReq.Params.Name = "shrinking_prompt"
getReq.Params.Arguments = map[string]string{"name": "Bob"}
getResult, err := mcpClient.GetPrompt(ctx, getReq)
require.NoError(s.T(), err, "GetPrompt failed for updated prompt")
require.Len(s.T(), getResult.Messages, 1, "Expected exactly 1 message")
content, ok := getResult.Messages[0].Content.(mcp.TextContent)
require.True(s.T(), ok, "Expected TextContent")
assert.Contains(s.T(), content.Text, "Hello Bob!", "Unexpected updated prompt content")
assert.NotContains(s.T(), content.Text, "age", "Should not contain age reference after removal")
}
// TestReloadPromptsDescriptionChanged tests reloadPrompts method with description changes via ServeStdio
func (s *PromptsServerTestSuite) TestReloadPromptsDescriptionChanged() {
ctx := context.Background()
// Create initial prompt with original description
promptFile := filepath.Join(s.tempDir, "descriptive_prompt.tmpl")
initialContent := `{{/* Original description */}}
Hello {{.name}}!`
err := os.WriteFile(promptFile, []byte(initialContent), 0644)
require.NoError(s.T(), err, "Failed to write initial prompt file")
// Create prompts server that will watch the temp directory
_, mcpClient, promptsClose := s.makePromptsServerAndClient(ctx, s.tempDir, true)
defer promptsClose()
// Verify initial description
listResult, err := mcpClient.ListPrompts(ctx, mcp.ListPromptsRequest{})
require.NoError(s.T(), err, "ListPrompts failed")
require.Len(s.T(), listResult.Prompts, 1, "Expected 1 prompt initially")
assert.Equal(s.T(), "Original description", listResult.Prompts[0].Description, "Expected original description")
// Update prompt file with new description
updatedContent := `{{/* Updated description with more details */}}
Hello {{.name}}!`
err = os.WriteFile(promptFile, []byte(updatedContent), 0644)
require.NoError(s.T(), err, "Failed to update prompt file")
// Give the client-server communication time to process the changes
time.Sleep(100 * time.Millisecond)
// Client should now see the updated description
listResult, err = mcpClient.ListPrompts(ctx, mcp.ListPromptsRequest{})
require.NoError(s.T(), err, "ListPrompts failed after description change")
require.Len(s.T(), listResult.Prompts, 1, "Expected 1 prompt after update")
assert.Equal(s.T(), "Updated description with more details", listResult.Prompts[0].Description, "Expected updated description")
// Verify client can still call the prompt and gets updated description
getReq := mcp.GetPromptRequest{}
getReq.Params.Name = "descriptive_prompt"
getReq.Params.Arguments = map[string]string{"name": "Charlie"}
getResult, err := mcpClient.GetPrompt(ctx, getReq)
require.NoError(s.T(), err, "GetPrompt failed for updated prompt")
require.Len(s.T(), getResult.Messages, 1, "Expected exactly 1 message")
content, ok := getResult.Messages[0].Content.(mcp.TextContent)
require.True(s.T(), ok, "Expected TextContent")
assert.Contains(s.T(), content.Text, "Hello Charlie!", "Prompt functionality should remain the same")
assert.Equal(s.T(), "Updated description with more details", getResult.Description, "GetPrompt should return updated description")
}
func (s *PromptsServerTestSuite) makePromptsServerAndClient(
ctx context.Context, promptsDir string, enableJSONArgs bool,
) (*PromptsServer, *client.Client, func()) {
var ctxCancel context.CancelFunc
ctx, ctxCancel = context.WithCancel(ctx)
// Create prompts server that will watch the temp directory
promptsServer, err := NewPromptsServer(promptsDir, enableJSONArgs, s.logger)
require.NoError(s.T(), err, "Failed to create prompts server")
// Set up pipes for client-server communication
serverReader, clientWriter := io.Pipe()
clientReader, serverWriter := io.Pipe()
// Start the server in a goroutine
errChan := make(chan error, 1)
go func() {
errChan <- promptsServer.ServeStdio(ctx, serverReader, serverWriter)
}()
// Create transport and client
var logBuffer bytes.Buffer
transp := transport.NewIO(clientReader, clientWriter, io.NopCloser(&logBuffer))
err = transp.Start(ctx)
require.NoError(s.T(), err, "Failed to start transport")
mcpClient := client.NewClient(transp)
// Initialize the client
var initReq mcp.InitializeRequest
initReq.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
_, err = mcpClient.Initialize(ctx, initReq)
require.NoError(s.T(), err, "Failed to initialize client")
return promptsServer, mcpClient, func() {
ctxCancel()
s.Require().NoError(<-errChan)
s.Require().NoError(transp.Close())
s.Require().NoError(promptsServer.Close())
}
}
```