chore: initial release v0.1.0
ci / test (push) Failing after 5s
ci / lint (push) Failing after 4s

forge-toolkit: хелперы для разработки MCP-серверов на Go.

- toolkit: args/schema/result/env/probe/server
- toolkit/validate: валидаторы синтаксиса
- configreload: live-reload конфига по контент-хэшу
- docs: ARCHITECTURE, quickstart, mcp-contract
- template/ и examples/hello-tool/
- CI (Gitea Actions), лицензия Apache-2.0
This commit is contained in:
Maksim Totmin
2026-09-14 00:23:08 +07:00
commit c398045664
43 changed files with 2581 additions and 0 deletions
+324
View File
@@ -0,0 +1,324 @@
// Package validate — серверные (Go-native) валидаторы синтаксиса и детект
// типа файла по расширению. Все парсеры выполняются на MCP-сервере (ноль
// зависимостей от удалённого хоста). Вынесено из нескольких MCP-серверов,
// где код дублировался почти дословно.
package validate
import (
"bufio"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"path/filepath"
"strings"
"github.com/BurntSushi/toml"
"gopkg.in/yaml.v3"
)
// Result — итог проверки синтаксиса.
type Result struct {
Valid bool
FileType string
Errors []string
}
// FormatResult возвращает человекочитаемую сводку.
func (v *Result) FormatResult(path string) string {
if v.Valid {
return fmt.Sprintf("✓ Valid %s — %s", strings.ToUpper(v.FileType), path)
}
var b strings.Builder
fmt.Fprintf(&b, "✗ INVALID %s — %s\n", strings.ToUpper(v.FileType), path)
for _, e := range v.Errors {
b.WriteString(" " + e + "\n")
}
return strings.TrimRight(b.String(), "\n")
}
// Content валидирует сырой контент по типу файла. nil — тип не распознан
// (валидация невозможна).
func Content(content, fileType string) *Result {
switch fileType {
case "json":
return validateJSON(content)
case "yaml":
return validateYAML(content)
case "toml":
return validateTOML(content)
case "xml":
return validateXML(content)
case "ini":
return validateINI(content)
case "env":
return validateENV(content)
case "dockerfile":
return validateDockerfile(content)
default:
return nil
}
}
// --- JSON ---
func validateJSON(content string) *Result {
r := &Result{FileType: "json"}
var v interface{}
if err := json.Unmarshal([]byte(content), &v); err != nil {
r.Errors = append(r.Errors, err.Error())
return r
}
r.Valid = true
return r
}
// --- YAML ---
func validateYAML(content string) *Result {
r := &Result{FileType: "yaml"}
// Decode all documents (multi-doc YAML support)
dec := yaml.NewDecoder(strings.NewReader(content))
for {
var v interface{}
err := dec.Decode(&v)
if err == io.EOF {
break
}
if err != nil {
r.Errors = append(r.Errors, err.Error())
return r
}
}
r.Valid = true
return r
}
// --- TOML ---
func validateTOML(content string) *Result {
r := &Result{FileType: "toml"}
var v interface{}
if _, err := toml.Decode(content, &v); err != nil {
r.Errors = append(r.Errors, err.Error())
return r
}
r.Valid = true
return r
}
// --- XML ---
func validateXML(content string) *Result {
r := &Result{FileType: "xml"}
dec := xml.NewDecoder(strings.NewReader(content))
for {
_, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
r.Errors = append(r.Errors, err.Error())
return r
}
}
r.Valid = true
return r
}
// --- INI / .conf / .cfg ---
// Simple validator: checks section headers [section] and key=value pairs.
// Allows comments (# and ;) and blank lines.
func validateINI(content string) *Result {
r := &Result{FileType: "ini"}
scanner := bufio.NewScanner(strings.NewReader(content))
lineNum := 0
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
// Blank or comment
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
// Section header
if strings.HasPrefix(line, "[") {
if !strings.HasSuffix(line, "]") {
r.Errors = append(r.Errors, fmt.Sprintf("line %d: unclosed section header: %s", lineNum, line))
}
continue
}
// Key=value (allow key = value, key: value)
if strings.ContainsAny(line, "=:") {
continue
}
r.Errors = append(r.Errors, fmt.Sprintf("line %d: invalid syntax: %s", lineNum, line))
}
r.Valid = len(r.Errors) == 0
return r
}
// --- .env / dotenv ---
// Validates KEY=VALUE format. Allows comments (#) and blank lines.
// Keys must start with a letter or underscore.
func validateENV(content string) *Result {
r := &Result{FileType: "env"}
scanner := bufio.NewScanner(strings.NewReader(content))
lineNum := 0
for scanner.Scan() {
lineNum++
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
// Must contain = and key must start with letter/underscore
eqIdx := strings.Index(line, "=")
if eqIdx <= 0 {
r.Errors = append(r.Errors, fmt.Sprintf("line %d: missing KEY=VALUE format: %s", lineNum, line))
continue
}
key := line[:eqIdx]
key = strings.TrimSpace(key)
// Remove "export " prefix if present
key = strings.TrimPrefix(key, "export ")
key = strings.TrimSpace(key)
if key == "" {
r.Errors = append(r.Errors, fmt.Sprintf("line %d: empty key", lineNum))
continue
}
firstChar := key[0]
isLetter := firstChar >= 'A' && firstChar <= 'Z' || firstChar >= 'a' && firstChar <= 'z'
if !isLetter && firstChar != '_' {
r.Errors = append(r.Errors, fmt.Sprintf("line %d: key must start with letter or underscore: %s", lineNum, key))
}
}
r.Valid = len(r.Errors) == 0
return r
}
// --- Dockerfile ---
// Validates that each non-comment, non-continuation line starts with a known instruction.
var dockerfileInstructions = map[string]bool{
"FROM": true, "RUN": true, "CMD": true, "LABEL": true,
"EXPOSE": true, "ENV": true, "ADD": true, "COPY": true,
"ENTRYPOINT": true, "VOLUME": true, "USER": true, "WORKDIR": true,
"ARG": true, "ONBUILD": true, "STOPSIGNAL": true, "HEALTHCHECK": true,
"SHELL": true, "MAINTAINER": true,
}
func validateDockerfile(content string) *Result {
r := &Result{FileType: "dockerfile"}
scanner := bufio.NewScanner(strings.NewReader(content))
lineNum := 0
continuation := false
for scanner.Scan() {
lineNum++
line := scanner.Text()
trimmed := strings.TrimSpace(line)
// Handle line continuation from previous line
if continuation {
continuation = strings.HasSuffix(trimmed, "\\")
continue
}
// Skip blank lines and comments
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
// Check if line continues
continuation = strings.HasSuffix(trimmed, "\\")
// Extract instruction (first word)
parts := strings.Fields(trimmed)
if len(parts) == 0 {
continue
}
instruction := strings.ToUpper(parts[0])
// Handle parser directives (# syntax=..., # escape=...)
if strings.HasPrefix(instruction, "#") {
continue
}
if !dockerfileInstructions[instruction] {
r.Errors = append(r.Errors, fmt.Sprintf("line %d: unknown instruction: %s", lineNum, parts[0]))
}
}
// Check that FROM is present
hasFrom := false
scanner2 := bufio.NewScanner(strings.NewReader(content))
for scanner2.Scan() {
line := strings.TrimSpace(scanner2.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.Fields(line)
if len(parts) > 0 && strings.ToUpper(parts[0]) == "FROM" {
hasFrom = true
break
}
}
if !hasFrom && strings.TrimSpace(content) != "" {
r.Errors = append(r.Errors, "missing FROM instruction")
}
r.Valid = len(r.Errors) == 0
return r
}
// fileTypePatterns — сопоставление lowercase basename с типом файла.
var fileTypePatterns = []struct {
pattern string // matched against lowercase basename
fileType string
}{
{"*.json", "json"},
{"*.yaml", "yaml"},
{"*.yml", "yaml"},
{"*.toml", "toml"},
{"*.xml", "xml"},
{"*.xsl", "xml"},
{"*.xslt", "xml"},
{"*.svg", "xml"},
{"*.xhtml", "xml"},
{"*.plist", "xml"},
{"*.ini", "ini"},
{"*.cfg", "ini"},
{"*.conf", "ini"},
{"*.env", "env"},
{"dockerfile*", "dockerfile"},
{".env*", "env"},
}
// DetectFileType определяет тип файла по расширению или имени. Пусто — тип
// не распознан.
func DetectFileType(path string) string {
lower := strings.ToLower(path)
base := lower
if idx := strings.LastIndex(lower, "/"); idx >= 0 {
base = lower[idx+1:]
}
for _, p := range fileTypePatterns {
if matched, _ := filepath.Match(p.pattern, base); matched {
return p.fileType
}
}
return ""
}