## Summary Follow-up to #1145. When `mcpName` is set in `package.json`, it should be the server name used by `mcp-publisher init`. Two issues addressed: ### 1. `getNameFromPackageJSON` mangled `mcpName` through npm-name transformation `mcpName` values are already in MCP server name format (e.g. `io.github.foo/bar`). The existing code ran them through the `@scope/name → io.github.scope/name` transform and its `io.github.<your-username>/*` fallback, producing a broken double-prefix result. Example with `"mcpName": "io.github.acme/weather"`: - Before: `io.github.<your-username>/io.github.acme/weather` ❌ - After: `io.github.acme/weather` ✅ ### 2. `detectServerName` consulted git before `mcpName` Even with `mcpName` set in `package.json`, a github.com git remote (or a `repository` field) silently won — the function returned `io.github.<owner>/<repo>` and never looked at `mcpName`. Per the [quickstart docs](https://github.com/modelcontextprotocol/registry/blob/main/docs/modelcontextprotocol-io/quickstart.mdx), *"The value of `mcpName` will be your server's name in the MCP Registry"*, so `mcpName` should take precedence. ## Changes - `cmd/publisher/commands/init.go`: - New `getMcpNameFromPackageJSON()` helper that returns `mcpName` or `""`. - `detectServerName` checks `mcpName` first, before git remote and the `name`-based fallback. - `getNameFromPackageJSON` simplified — the now-redundant `mcpName` branch was removed; it only handles the npm `name` transformation. - `cmd/publisher/commands/init_test.go` (new): 5 cases through the public `InitCommand` API covering `mcpName` as-is, fallback to scoped `name`, fallback to placeholder, version fallback, and `mcpName` winning over a GitHub `repository` URL. This completes the fix for #737. ## Test plan - [x] `go test ./cmd/publisher/commands/` passes (5 new subtests) - [x] `golangci-lint run ./cmd/publisher/commands/` clean - [ ] Manual: `mcp-publisher init` in a git-tracked directory with `package.json` containing `mcpName` produces a `server.json` whose `name` matches `mcpName`, not the repo slug 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
465 lines
12 KiB
Go
465 lines
12 KiB
Go
package commands
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
apiv0 "github.com/modelcontextprotocol/registry/pkg/api/v0"
|
|
"github.com/modelcontextprotocol/registry/pkg/model"
|
|
)
|
|
|
|
func InitCommand() error {
|
|
// Check if server.json already exists
|
|
if _, err := os.Stat("server.json"); err == nil {
|
|
return errors.New("server.json already exists")
|
|
}
|
|
|
|
// Detect if we're in a subdirectory of the git repository
|
|
subfolder := detectSubfolder()
|
|
|
|
// Try to detect values from environment
|
|
name := detectServerName(subfolder)
|
|
description := detectDescription()
|
|
version := getVersionFromPackageJSON()
|
|
if version == "" {
|
|
version = "1.0.0"
|
|
}
|
|
repoURL := detectRepoURL()
|
|
repoSource := MethodGitHub
|
|
if repoURL != "" && !strings.Contains(repoURL, "github.com") {
|
|
if strings.Contains(repoURL, "gitlab.com") {
|
|
repoSource = "gitlab"
|
|
} else {
|
|
repoSource = "git"
|
|
}
|
|
}
|
|
|
|
packageType := detectPackageType()
|
|
packageIdentifier := detectPackageIdentifier(name, packageType)
|
|
|
|
// Create example environment variables
|
|
envVars := []model.KeyValueInput{
|
|
{
|
|
Name: "YOUR_API_KEY",
|
|
InputWithVariables: model.InputWithVariables{
|
|
Input: model.Input{
|
|
Description: "Your API key for the service",
|
|
IsRequired: true,
|
|
IsSecret: true,
|
|
Format: model.FormatString,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Create the server structure
|
|
server := createServerJSON(
|
|
model.CurrentSchemaURL, name, description, version, repoURL, repoSource, subfolder,
|
|
packageType, packageIdentifier, version, envVars,
|
|
)
|
|
|
|
// Write to file
|
|
jsonData, err := json.MarshalIndent(server, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("error marshaling JSON: %w", err)
|
|
}
|
|
|
|
err = os.WriteFile("server.json", jsonData, 0600)
|
|
if err != nil {
|
|
return fmt.Errorf("error writing file: %w", err)
|
|
}
|
|
|
|
_, _ = fmt.Fprintln(os.Stdout, "Created server.json")
|
|
_, _ = fmt.Fprintln(os.Stdout, "\nEdit server.json to update:")
|
|
_, _ = fmt.Fprintln(os.Stdout, " • Server name and description")
|
|
_, _ = fmt.Fprintln(os.Stdout, " • Package details")
|
|
_, _ = fmt.Fprintln(os.Stdout, " • Environment variables")
|
|
_, _ = fmt.Fprintln(os.Stdout, "\nThen publish with:")
|
|
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher login github # or your preferred auth method")
|
|
_, _ = fmt.Fprintln(os.Stdout, " mcp-publisher publish")
|
|
|
|
return nil
|
|
}
|
|
|
|
func detectSubfolder() string {
|
|
// Get current working directory
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
// Find git repository root
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
|
|
cmd.Dir = cwd
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
// Not in a git repository
|
|
return ""
|
|
}
|
|
|
|
gitRoot := strings.TrimSpace(string(output))
|
|
|
|
// Clean the paths to ensure proper comparison
|
|
gitRoot = filepath.Clean(gitRoot)
|
|
cwd = filepath.Clean(cwd)
|
|
|
|
// If we're in the root, no subfolder
|
|
if gitRoot == cwd {
|
|
return ""
|
|
}
|
|
|
|
// Check if cwd is actually within gitRoot
|
|
if !strings.HasPrefix(cwd, gitRoot) {
|
|
return ""
|
|
}
|
|
|
|
// Calculate relative path from git root to current directory
|
|
relPath, err := filepath.Rel(gitRoot, cwd)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
// Convert to forward slashes for consistency (important for cross-platform)
|
|
return filepath.ToSlash(relPath)
|
|
}
|
|
|
|
// getMcpNameFromPackageJSON returns the `mcpName` field from package.json, or
|
|
// "" if not set. mcpName is the authoritative MCP server name when present —
|
|
// see docs/modelcontextprotocol-io/quickstart.mdx.
|
|
func getMcpNameFromPackageJSON() string {
|
|
data, err := os.ReadFile("package.json")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
var pkg map[string]any
|
|
if err := json.Unmarshal(data, &pkg); err != nil {
|
|
return ""
|
|
}
|
|
|
|
if mcpName, ok := pkg["mcpName"].(string); ok && mcpName != "" {
|
|
return mcpName
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func getNameFromPackageJSON() string {
|
|
data, err := os.ReadFile("package.json")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
var pkg map[string]any
|
|
if err := json.Unmarshal(data, &pkg); err != nil {
|
|
return ""
|
|
}
|
|
|
|
name, ok := pkg["name"].(string)
|
|
if !ok || name == "" {
|
|
return ""
|
|
}
|
|
|
|
// Convert npm package name to MCP server name
|
|
// @org/package -> io.github.org/package
|
|
if strings.HasPrefix(name, "@") {
|
|
parts := strings.Split(name[1:], "/")
|
|
if len(parts) == 2 {
|
|
return fmt.Sprintf("io.github.%s/%s", parts[0], parts[1])
|
|
}
|
|
}
|
|
return fmt.Sprintf("io.github.<your-username>/%s", name)
|
|
}
|
|
|
|
func getVersionFromPackageJSON() string {
|
|
data, err := os.ReadFile("package.json")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
var pkg map[string]any
|
|
if err := json.Unmarshal(data, &pkg); err != nil {
|
|
return ""
|
|
}
|
|
|
|
version, ok := pkg["version"].(string)
|
|
if !ok || version == "" {
|
|
return ""
|
|
}
|
|
|
|
return version
|
|
}
|
|
|
|
func detectServerName(subfolder string) string {
|
|
// mcpName in package.json is the authoritative server name when set, so it
|
|
// takes precedence over names inferred from the git remote or the npm
|
|
// `name` field.
|
|
if name := getMcpNameFromPackageJSON(); name != "" {
|
|
return name
|
|
}
|
|
|
|
// Try to get from git remote
|
|
repoURL := detectRepoURL()
|
|
if repoURL != "" && strings.Contains(repoURL, "github.com") {
|
|
name := buildGitHubServerName(repoURL, subfolder)
|
|
if name != "" {
|
|
return name
|
|
}
|
|
}
|
|
|
|
// Try to get from package.json
|
|
name := getNameFromPackageJSON()
|
|
if name != "" {
|
|
return name
|
|
}
|
|
|
|
// Use current directory name as fallback
|
|
if cwd, err := os.Getwd(); err == nil {
|
|
return fmt.Sprintf("com.example/%s", filepath.Base(cwd))
|
|
}
|
|
|
|
return "com.example/my-mcp-server"
|
|
}
|
|
|
|
func buildGitHubServerName(repoURL, subfolder string) string {
|
|
parts := strings.Split(repoURL, "/")
|
|
if len(parts) < 5 {
|
|
return ""
|
|
}
|
|
|
|
owner := parts[3]
|
|
repo := strings.TrimSuffix(parts[4], ".git")
|
|
|
|
// If we're in a subdirectory, use the current folder name
|
|
if subfolder != "" {
|
|
folderName := filepath.Base(subfolder)
|
|
return fmt.Sprintf("io.github.%s/%s", owner, folderName)
|
|
}
|
|
|
|
return fmt.Sprintf("io.github.%s/%s", owner, repo)
|
|
}
|
|
|
|
func detectDescription() string {
|
|
// Try to get from package.json
|
|
if data, err := os.ReadFile("package.json"); err == nil {
|
|
var pkg map[string]any
|
|
if json.Unmarshal(data, &pkg) == nil {
|
|
if desc, ok := pkg["description"].(string); ok && desc != "" {
|
|
return desc
|
|
}
|
|
}
|
|
}
|
|
|
|
return "An MCP server that provides [describe what your server does]"
|
|
}
|
|
|
|
func detectRepoURL() string {
|
|
sanitizeURL := func(url string) string {
|
|
return strings.TrimPrefix(url, "git+")
|
|
}
|
|
|
|
// Try git remote
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
cmd := exec.CommandContext(ctx, "git", "remote", "get-url", "origin")
|
|
if output, err := cmd.Output(); err == nil {
|
|
url := strings.TrimSpace(string(output))
|
|
// Convert SSH URL to HTTPS if needed
|
|
if strings.HasPrefix(url, "git@github.com:") {
|
|
url = strings.Replace(url, "git@github.com:", "https://github.com/", 1)
|
|
}
|
|
url = strings.TrimSuffix(url, ".git")
|
|
return url
|
|
}
|
|
|
|
// Try package.json repository field
|
|
if data, err := os.ReadFile("package.json"); err == nil {
|
|
var pkg map[string]any
|
|
if json.Unmarshal(data, &pkg) == nil {
|
|
if repo, ok := pkg["repository"].(map[string]any); ok {
|
|
if url, ok := repo["url"].(string); ok {
|
|
return sanitizeURL(strings.TrimSuffix(url, ".git"))
|
|
}
|
|
}
|
|
if repo, ok := pkg["repository"].(string); ok {
|
|
return sanitizeURL(strings.TrimSuffix(repo, ".git"))
|
|
}
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func detectPackageType() string {
|
|
// Check for package.json
|
|
if _, err := os.Stat("package.json"); err == nil {
|
|
return model.RegistryTypeNPM
|
|
}
|
|
|
|
// Check for pyproject.toml or setup.py
|
|
if _, err := os.Stat("pyproject.toml"); err == nil {
|
|
return model.RegistryTypePyPI
|
|
}
|
|
if _, err := os.Stat("setup.py"); err == nil {
|
|
return model.RegistryTypePyPI
|
|
}
|
|
|
|
// Check for Dockerfile
|
|
if _, err := os.Stat("Dockerfile"); err == nil {
|
|
return model.RegistryTypeOCI
|
|
}
|
|
|
|
// Default to npm as most common
|
|
return model.RegistryTypeNPM
|
|
}
|
|
|
|
func detectPackageIdentifier(serverName string, packageType string) string {
|
|
switch packageType {
|
|
case model.RegistryTypeNPM:
|
|
// Try to get from package.json
|
|
if data, err := os.ReadFile("package.json"); err == nil {
|
|
var pkg map[string]any
|
|
if json.Unmarshal(data, &pkg) == nil {
|
|
if name, ok := pkg["name"].(string); ok && name != "" {
|
|
return name
|
|
}
|
|
}
|
|
}
|
|
// Convert server name to npm package name
|
|
if strings.HasPrefix(serverName, "io.github.") {
|
|
parts := strings.Split(serverName, "/")
|
|
if len(parts) == 2 {
|
|
owner := strings.TrimPrefix(parts[0], "io.github.")
|
|
return fmt.Sprintf("@%s/%s", owner, parts[1])
|
|
}
|
|
}
|
|
return "@your-org/your-package"
|
|
|
|
case model.RegistryTypePyPI:
|
|
// Try to get from pyproject.toml or setup.py
|
|
if data, err := os.ReadFile("pyproject.toml"); err == nil {
|
|
// Simple extraction - could be improved with proper TOML parser
|
|
lines := strings.Split(string(data), "\n")
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(line, "name") && strings.Contains(line, "=") {
|
|
parts := strings.Split(line, "=")
|
|
if len(parts) >= 2 {
|
|
name := strings.Trim(parts[1], " \"'")
|
|
if name != "" {
|
|
return name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return "your-package"
|
|
|
|
case model.RegistryTypeOCI:
|
|
// Use a sensible default
|
|
if strings.Contains(serverName, "/") {
|
|
parts := strings.Split(serverName, "/")
|
|
return parts[len(parts)-1]
|
|
}
|
|
return "your-image"
|
|
|
|
default:
|
|
return "your-package"
|
|
}
|
|
}
|
|
|
|
func createServerJSON(
|
|
currentSchema, name, description, version, repoURL, repoSource, subfolder,
|
|
packageType, packageIdentifier, packageVersion string,
|
|
envVars []model.KeyValueInput,
|
|
) apiv0.ServerJSON {
|
|
// Create package based on type
|
|
var pkg model.Package
|
|
|
|
switch packageType {
|
|
case model.RegistryTypeNPM:
|
|
pkg = model.Package{
|
|
RegistryType: model.RegistryTypeNPM,
|
|
Identifier: packageIdentifier,
|
|
Version: packageVersion,
|
|
EnvironmentVariables: envVars,
|
|
Transport: model.Transport{
|
|
Type: model.TransportTypeStdio,
|
|
},
|
|
}
|
|
case model.RegistryTypePyPI:
|
|
pkg = model.Package{
|
|
RegistryType: model.RegistryTypePyPI,
|
|
Identifier: packageIdentifier,
|
|
Version: packageVersion,
|
|
EnvironmentVariables: envVars,
|
|
Transport: model.Transport{
|
|
Type: model.TransportTypeStdio,
|
|
},
|
|
}
|
|
case model.RegistryTypeOCI:
|
|
// OCI packages use canonical references: registry/namespace/image:tag
|
|
// Format: docker.io/username/image:version
|
|
canonicalRef := fmt.Sprintf("docker.io/%s:%s", packageIdentifier, packageVersion)
|
|
pkg = model.Package{
|
|
RegistryType: model.RegistryTypeOCI,
|
|
Identifier: canonicalRef,
|
|
// No Version field for OCI - it's embedded in the canonical reference
|
|
EnvironmentVariables: envVars,
|
|
Transport: model.Transport{
|
|
Type: model.TransportTypeStdio,
|
|
},
|
|
}
|
|
case "url":
|
|
pkg = model.Package{
|
|
RegistryType: "url",
|
|
Identifier: packageIdentifier,
|
|
Version: packageVersion,
|
|
EnvironmentVariables: envVars,
|
|
Transport: model.Transport{
|
|
Type: model.TransportTypeStdio,
|
|
},
|
|
}
|
|
default:
|
|
pkg = model.Package{
|
|
RegistryType: packageType,
|
|
Identifier: packageIdentifier,
|
|
Version: packageVersion,
|
|
EnvironmentVariables: envVars,
|
|
Transport: model.Transport{
|
|
Type: model.TransportTypeStdio,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Create repository with optional subfolder
|
|
var repo *model.Repository
|
|
if repoURL != "" && repoSource != "" {
|
|
repo = &model.Repository{
|
|
URL: repoURL,
|
|
Source: repoSource,
|
|
}
|
|
|
|
// Only set subfolder if we're actually in a subdirectory
|
|
if subfolder != "" {
|
|
repo.Subfolder = subfolder
|
|
}
|
|
}
|
|
|
|
// Create server structure
|
|
return apiv0.ServerJSON{
|
|
Schema: currentSchema,
|
|
Name: name,
|
|
Description: description,
|
|
Repository: repo,
|
|
Version: version,
|
|
Packages: []model.Package{pkg},
|
|
}
|
|
}
|