diff --git a/docs/reference/api/openapi.yaml b/docs/reference/api/openapi.yaml index 61cae2da..e6b96848 100644 --- a/docs/reference/api/openapi.yaml +++ b/docs/reference/api/openapi.yaml @@ -720,6 +720,35 @@ components: description: A mapping of environment variables to be set when running the package. items: $ref: '#/components/schemas/KeyValueInput' + postInstallInstructions: + type: array + description: "Structured post-install steps that clients can display as a checklist after downloading the package. Intended for servers that require additional manual setup beyond installation (for example, registering a companion system service). Instructions are informational only - clients display text and never execute commands automatically." + items: + $ref: '#/components/schemas/PostInstallInstruction' + + PostInstallInstruction: + type: object + required: + - description + properties: + description: + type: string + description: Human-readable instruction text describing the post-install step. + minLength: 1 + example: "Install as a system service for the web UI" + command: + type: string + description: Optional shell command the user can run to complete the step. Displayed for the user to copy; clients must not execute it automatically. + example: "mind-map service install --addr 127.0.0.1:4242" + documentation: + type: string + format: uri + description: Optional link to documentation or a setup page describing the step in more detail. + example: "https://github.com/aniongithub/mind-map#service-management" + optional: + type: boolean + description: Whether the step is optional. When true, the MCP server functions without completing this step. + default: false Input: type: object diff --git a/docs/reference/server-json/CHANGELOG.md b/docs/reference/server-json/CHANGELOG.md index c55ef417..ea523630 100644 --- a/docs/reference/server-json/CHANGELOG.md +++ b/docs/reference/server-json/CHANGELOG.md @@ -6,6 +6,33 @@ Changes to the server.json schema and format. This section tracks changes that are in development and not yet released. The draft schema is available at [`server.schema.json`](./draft/server.schema.json) in this repository. +### Added + +#### Optional `postInstallInstructions` on Packages + +Packages may now declare an optional `postInstallInstructions` array of structured post-install steps that clients can display as a checklist after downloading a package. This is intended for servers that require additional manual setup beyond installation — for example, a server that doubles as a system service and needs a companion daemon registered. Instructions are informational only: clients display the text and never execute commands automatically, so there is no supply chain risk. + +Each instruction has a required `description` and optional `command`, `documentation` (a URI), and `optional` (boolean) fields. + +**Example:** +```json +{ + "packages": [{ + "registryType": "mcpb", + "identifier": "https://github.com/aniongithub/mind-map/releases/download/v1.0.0/mind-map.mcpb", + "transport": { "type": "stdio" }, + "postInstallInstructions": [ + { + "description": "Install as a system service for the web UI", + "command": "mind-map service install --addr 127.0.0.1:4242", + "documentation": "https://github.com/aniongithub/mind-map#service-management", + "optional": true + } + ] + }] +} +``` + ### Changed #### Transport URL Pattern Now Accepts Template Variables diff --git a/docs/reference/server-json/draft/server.schema.json b/docs/reference/server-json/draft/server.schema.json index e3aeab6a..1a09a893 100644 --- a/docs/reference/server-json/draft/server.schema.json +++ b/docs/reference/server-json/draft/server.schema.json @@ -234,6 +234,13 @@ }, "type": "array" }, + "postInstallInstructions": { + "description": "Structured post-install steps that clients can display as a checklist after downloading the package. Intended for servers that require additional manual setup beyond installation (for example, registering a companion system service). Instructions are informational only - clients display text and never execute commands automatically.", + "items": { + "$ref": "#/definitions/PostInstallInstruction" + }, + "type": "array" + }, "registryBaseUrl": { "description": "Base URL of the package registry", "examples": [ @@ -344,6 +351,36 @@ ], "description": "A positional input is a value inserted verbatim into the command line." }, + "PostInstallInstruction": { + "properties": { + "command": { + "description": "Optional shell command the user can run to complete the step. Displayed for the user to copy; clients must not execute it automatically.", + "example": "mind-map service install --addr 127.0.0.1:4242", + "type": "string" + }, + "description": { + "description": "Human-readable instruction text describing the post-install step.", + "example": "Install as a system service for the web UI", + "minLength": 1, + "type": "string" + }, + "documentation": { + "description": "Optional link to documentation or a setup page describing the step in more detail.", + "example": "https://github.com/aniongithub/mind-map#service-management", + "format": "uri", + "type": "string" + }, + "optional": { + "default": false, + "description": "Whether the step is optional. When true, the MCP server functions without completing this step.", + "type": "boolean" + } + }, + "required": [ + "description" + ], + "type": "object" + }, "RemoteTransport": { "allOf": [ { diff --git a/internal/validators/post_install_instructions_test.go b/internal/validators/post_install_instructions_test.go new file mode 100644 index 00000000..7b2d5623 --- /dev/null +++ b/internal/validators/post_install_instructions_test.go @@ -0,0 +1,156 @@ +package validators_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// draftSchemaPath is the in-repo draft schema, the source of truth for unreleased +// schema changes such as postInstallInstructions. +const draftSchemaPath = "../../docs/reference/server-json/draft/server.schema.json" + +// compileDraftSchema loads and compiles the in-repo draft server.json schema so +// tests can validate documents against the unreleased schema definition. +func compileDraftSchema(t *testing.T) *jsonschema.Schema { + t.Helper() + + absPath, err := filepath.Abs(draftSchemaPath) + require.NoError(t, err, "resolve draft schema path") + + schemaData, err := os.ReadFile(absPath) + require.NoError(t, err, "read draft schema file") + + schemaID := "https://raw.githubusercontent.com/modelcontextprotocol/registry/main/docs/reference/server-json/draft/server.schema.json" + + compiler := jsonschema.NewCompiler() + require.NoError(t, compiler.AddResource(schemaID, strings.NewReader(string(schemaData))), "add draft schema resource") + + schema, err := compiler.Compile(schemaID) + require.NoError(t, err, "compile draft schema") + return schema +} + +// validateAgainstDraft validates a raw server.json document against the draft schema +// and returns any validation error. +func validateAgainstDraft(t *testing.T, schema *jsonschema.Schema, doc string) error { + t.Helper() + + var instance interface{} + require.NoError(t, json.Unmarshal([]byte(doc), &instance), "unmarshal server.json document") + return schema.Validate(instance) +} + +const baseServerWithPackagePrefix = `{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.aniongithub/mind-map", + "description": "A mind-map MCP server that doubles as a system service", + "version": "1.0.0", + "packages": [` + +const baseServerSuffix = `] +}` + +// TestDraftSchema_PostInstallInstructions_Accepted ensures a package carrying +// postInstallInstructions validates cleanly against the draft schema. +func TestDraftSchema_PostInstallInstructions_Accepted(t *testing.T) { + schema := compileDraftSchema(t) + + doc := baseServerWithPackagePrefix + `{ + "registryType": "mcpb", + "identifier": "https://github.com/aniongithub/mind-map/releases/download/v1.0.0/mind-map.mcpb", + "version": "1.0.0", + "fileSha256": "fe333e598595000ae021bd27117db32ec69af6987f507ba7a63c90638ff633ce", + "transport": { "type": "stdio" }, + "postInstallInstructions": [ + { + "description": "Install as a system service for the web UI", + "command": "mind-map service install --addr 127.0.0.1:4242", + "documentation": "https://github.com/aniongithub/mind-map#service-management", + "optional": true + } + ] + }` + baseServerSuffix + + err := validateAgainstDraft(t, schema, doc) + assert.NoError(t, err, "server.json with postInstallInstructions should be valid") +} + +// TestDraftSchema_PostInstallInstructions_OptionalWhenAbsent ensures the field +// remains optional: a package without it must still validate. +func TestDraftSchema_PostInstallInstructions_OptionalWhenAbsent(t *testing.T) { + schema := compileDraftSchema(t) + + doc := baseServerWithPackagePrefix + `{ + "registryType": "npm", + "identifier": "@example/server", + "version": "1.0.0", + "transport": { "type": "stdio" } + }` + baseServerSuffix + + err := validateAgainstDraft(t, schema, doc) + assert.NoError(t, err, "server.json without postInstallInstructions should remain valid") +} + +// TestDraftSchema_PostInstallInstructions_MinimalDescriptionOnly ensures only the +// required description field is needed for a valid instruction. +func TestDraftSchema_PostInstallInstructions_MinimalDescriptionOnly(t *testing.T) { + schema := compileDraftSchema(t) + + doc := baseServerWithPackagePrefix + `{ + "registryType": "npm", + "identifier": "@example/server", + "version": "1.0.0", + "transport": { "type": "stdio" }, + "postInstallInstructions": [ + { "description": "Restart your editor to load the server" } + ] + }` + baseServerSuffix + + err := validateAgainstDraft(t, schema, doc) + assert.NoError(t, err, "instruction with only a description should be valid") +} + +// TestDraftSchema_PostInstallInstructions_RequiresDescription ensures an +// instruction missing the required description field is rejected. +func TestDraftSchema_PostInstallInstructions_RequiresDescription(t *testing.T) { + schema := compileDraftSchema(t) + + doc := baseServerWithPackagePrefix + `{ + "registryType": "npm", + "identifier": "@example/server", + "version": "1.0.0", + "transport": { "type": "stdio" }, + "postInstallInstructions": [ + { "command": "do something" } + ] + }` + baseServerSuffix + + err := validateAgainstDraft(t, schema, doc) + assert.Error(t, err, "instruction without description should be rejected") +} + +// TestDraftSchema_PostInstallInstructions_DocumentationMustBeURI ensures the +// documentation field is validated as a URI. +func TestDraftSchema_PostInstallInstructions_DocumentationMustBeURI(t *testing.T) { + schema := compileDraftSchema(t) + + doc := baseServerWithPackagePrefix + `{ + "registryType": "npm", + "identifier": "@example/server", + "version": "1.0.0", + "transport": { "type": "stdio" }, + "postInstallInstructions": [ + { "description": "See docs", "documentation": "not a uri with spaces" } + ] + }` + baseServerSuffix + + err := validateAgainstDraft(t, schema, doc) + assert.Error(t, err, "non-URI documentation should be rejected") +} diff --git a/pkg/model/post_install_instructions_test.go b/pkg/model/post_install_instructions_test.go new file mode 100644 index 00000000..0248d2ad --- /dev/null +++ b/pkg/model/post_install_instructions_test.go @@ -0,0 +1,57 @@ +package model_test + +import ( + "encoding/json" + "testing" + + "github.com/modelcontextprotocol/registry/pkg/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestPackage_PostInstallInstructions_RoundTrip ensures the PostInstallInstructions +// field on Package serializes and deserializes losslessly. +func TestPackage_PostInstallInstructions_RoundTrip(t *testing.T) { + pkg := model.Package{ + RegistryType: "mcpb", + Identifier: "https://github.com/aniongithub/mind-map/releases/download/v1.0.0/mind-map.mcpb", + Transport: model.Transport{Type: "stdio"}, + PostInstallInstructions: []model.PostInstallInstruction{ + { + Description: "Install as a system service for the web UI", + Command: "mind-map service install --addr 127.0.0.1:4242", + Documentation: "https://github.com/aniongithub/mind-map#service-management", + Optional: true, + }, + }, + } + + data, err := json.Marshal(pkg) + require.NoError(t, err) + + var decoded model.Package + require.NoError(t, json.Unmarshal(data, &decoded)) + + require.Len(t, decoded.PostInstallInstructions, 1) + instruction := decoded.PostInstallInstructions[0] + assert.Equal(t, "Install as a system service for the web UI", instruction.Description) + assert.Equal(t, "mind-map service install --addr 127.0.0.1:4242", instruction.Command) + assert.Equal(t, "https://github.com/aniongithub/mind-map#service-management", instruction.Documentation) + assert.True(t, instruction.Optional) +} + +// TestPackage_PostInstallInstructions_OmittedWhenEmpty ensures the field is omitted +// from JSON output when unset, keeping it optional. +func TestPackage_PostInstallInstructions_OmittedWhenEmpty(t *testing.T) { + pkg := model.Package{ + RegistryType: "npm", + Identifier: "@example/server", + Version: "1.0.0", + Transport: model.Transport{Type: "stdio"}, + } + + data, err := json.Marshal(pkg) + require.NoError(t, err) + + assert.NotContains(t, string(data), "postInstallInstructions") +} diff --git a/pkg/model/types.go b/pkg/model/types.go index e9ade020..72ac5fac 100644 --- a/pkg/model/types.go +++ b/pkg/model/types.go @@ -49,6 +49,23 @@ type Package struct { PackageArguments []Argument `json:"packageArguments,omitempty" doc:"A list of arguments to be passed to the package's binary."` // EnvironmentVariables are set when running the package EnvironmentVariables []KeyValueInput `json:"environmentVariables,omitempty" doc:"A mapping of environment variables to be set when running the package."` + // PostInstallInstructions are informational steps clients can surface after download + PostInstallInstructions []PostInstallInstruction `json:"postInstallInstructions,omitempty" doc:"Structured post-install steps that clients can display as a checklist after downloading the package. Intended for servers that require additional manual setup beyond installation (for example, registering a companion system service). Instructions are informational only - clients display text and never execute commands automatically."` +} + +// PostInstallInstruction is a single informational post-install step that clients +// can display after downloading a package. It is purely descriptive: clients show +// the text and never execute the command automatically, so it carries no supply +// chain risk. +type PostInstallInstruction struct { + // Description is the human-readable instruction text (required). + Description string `json:"description" minLength:"1" doc:"Human-readable instruction text describing the post-install step." example:"Install as a system service for the web UI"` + // Command is an optional shell command the user can run; clients must not execute it automatically. + Command string `json:"command,omitempty" doc:"Optional shell command the user can run to complete the step. Displayed for the user to copy; clients must not execute it automatically." example:"mind-map service install --addr 127.0.0.1:4242"` + // Documentation is an optional link to docs or a setup page describing the step. + Documentation string `json:"documentation,omitempty" format:"uri" doc:"Optional link to documentation or a setup page describing the step in more detail." example:"https://github.com/aniongithub/mind-map#service-management"` + // Optional indicates whether the step is optional; when true the server works without it. + Optional bool `json:"optional,omitempty" doc:"Whether the step is optional. When true, the MCP server functions without completing this step."` } type Repository struct {