From 9a4e11e6a8f9a3180fb87cd0acea8696c5389298 Mon Sep 17 00:00:00 2001 From: Anneheartrecord Date: Sat, 13 Jun 2026 11:13:04 +0800 Subject: [PATCH] feat: add optional postInstallInstructions to package schema Add an optional postInstallInstructions array to the Package schema so servers can declare structured post-install steps that clients display as a checklist after downloading a package. This covers servers that require manual setup beyond installation, such as a stdio MCP server that also runs a companion system service (e.g. mind-map). Each instruction has a required description and optional command, documentation (URI), and optional fields. Instructions are informational only: clients display the text and never execute commands automatically, so there is no supply chain risk. Changes span the OpenAPI source of truth, the regenerated draft server.schema.json, the Go model used by the API, and the schema changelog. --- docs/reference/api/openapi.yaml | 29 ++++ docs/reference/server-json/CHANGELOG.md | 27 +++ .../server-json/draft/server.schema.json | 37 +++++ .../post_install_instructions_test.go | 156 ++++++++++++++++++ pkg/model/post_install_instructions_test.go | 57 +++++++ pkg/model/types.go | 17 ++ 6 files changed, 323 insertions(+) create mode 100644 internal/validators/post_install_instructions_test.go create mode 100644 pkg/model/post_install_instructions_test.go 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 {