Introduction: Enhancing the Client Experience
Every MCP server today requires manual configuration. Users copy-paste JSON blobs from README files, translate installation instructions into client-specific settings, and manually wire up transports. It's the "curl the install script" era of the MCP ecosystem.
The mcp-manifest.json specification changes this. By implementing manifest support in your MCP client, you enable autodiscovery, automated installation, and zero-config setup. Users type a domain name — your client does the rest.
This guide walks through the complete implementation: discovering manifests from URLs or domains, parsing and validating the JSON, detecting runtimes, executing installation commands, prompting users for configuration, generating client settings, and verifying the MCP handshake.
The manifest is the bridge between "paste this JSON blob" and "type a domain name." Your client is the engineer that builds it.
What You'll Build
A manifest-aware client that can:
- Discover manifests from domain names, URLs, or local files
- Parse and validate manifest JSON against the schema
- Install servers using the appropriate runtime (npm, pip, dotnet, cargo, Docker, binary)
- Prompt users for required configuration values with type-aware UI
- Generate client settings by substituting variables into templates
- Verify the connection with an MCP handshake
- Handle errors gracefully and provide actionable feedback
Let's build it.
Implementing the Discovery Algorithm
Autodiscovery follows the same proven pattern as RSS feeds: a well-known URL or HTML link tag, resolved automatically from a domain name.
The Resolution Algorithm
When a user provides input (e.g., typing ironlicensing.com into an "Add MCP Server" field), your client should resolve the manifest using this priority order:
INPUT: user_input (domain, URL, or file path)
1. If user_input is a local file path and exists:
→ Parse as manifest JSON. Done.
2. If user_input looks like a direct manifest URL (ends with .json):
→ Fetch and parse as manifest JSON. Done.
3. Normalize to a base URL:
- If no scheme, prepend "https://"
- If no path, use root "/"
→ base_url
4. Try well-known URL:
→ GET {base_url}/.well-known/mcp-manifest.json
- If 200 with valid JSON manifest: Done.
5. Fetch the HTML page:
→ GET {base_url}
- Parse HTML for <link rel="mcp-manifest"> tags
- If found: fetch the href URL, parse as manifest. Done.
6. Discovery failed.
→ Inform user that no manifest was found.
Implementation Tips
Handle multiple manifests gracefully. A single page may contain multiple <link rel="mcp-manifest"> tags for different servers:
<link rel="mcp-manifest" type="application/json"
href="/mcp-manifests/analytics.json"
title="Analytics Server" />
<link rel="mcp-manifest" type="application/json"
href="/mcp-manifests/licensing.json"
title="Licensing Server" />
Present all discovered manifests and let the user choose.
Normalize URLs carefully. Accept flexible input:
ironlicensing.com→https://ironlicensing.com/https://ironlicensing.com→https://ironlicensing.com/ironlicensing.com/mcp→https://ironlicensing.com/mcp
Follow redirects. The well-known URL or HTML page may redirect. Follow HTTP 301/302 responses.
Respect CORS. Manifests served from CDNs or different origins should include Access-Control-Allow-Origin: *.
Cache aggressively. Once discovered, cache the manifest URL for this domain. Don't re-discover on every client restart.
Example: Discovery Flow
User types ironlicensing.com:
- Client tries
https://ironlicensing.com/.well-known/mcp-manifest.json→ 200 OK - Parses the manifest:
{ "server": { "name": "ironlicensing", "displayName": "IronLicensing" }, "install": [{ "method": "dotnet-tool", "package": "IronLicensing.Mcp", "command": "ironlicensing-mcp" }] } - Proceeds to installation flow.
Total user effort: type domain name. Everything else is automated.
Parsing and Validating Manifests
Once you've fetched the manifest JSON, validate it against the schema and extract the fields your client needs.
Schema Validation
The manifest schema is published at https://mcp-manifest.dev/schema/v0.1.json. Use a JSON Schema validator in your language:
- JavaScript/TypeScript:
ajv - Python:
jsonschema - C#:
NJsonSchemaorNewtonsoft.Json.Schema - Rust:
jsonschema - Go:
gojsonschema
Example (TypeScript with Ajv):
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
const ajv = new Ajv();
addFormats(ajv);
const schemaUrl = 'https://mcp-manifest.dev/schema/v0.1.json';
const schema = await fetch(schemaUrl).then(r => r.json());
const validate = ajv.compile(schema);
const manifest = await fetch(manifestUrl).then(r => r.json());
if (!validate(manifest)) {
console.error('Invalid manifest:', validate.errors);
throw new Error('Manifest validation failed');
}
Required Fields
Every valid manifest MUST have:
version— spec version (currently"0.1")server.name— unique identifier (lowercase, hyphens allowed)server.displayName— human-readable nameserver.description— one-line descriptionserver.version— server version (semver)install— array of at least one installation methodtransport—"stdio","sse", or"streamable-http"
Handling Unknown Versions
If version is not "0.1", your client should:
- Warn the user that the manifest uses a newer/unknown spec version
- Attempt to parse using the known schema (forward compatibility)
- Gracefully degrade if critical fields are missing
Extracting Metadata
The server object contains metadata you can display in your UI:
{
"server": {
"name": "ironlicensing",
"displayName": "IronLicensing",
"description": "Manage IronLicensing products, tiers, features, licenses, and analytics",
"version": "1.0.0",
"author": "IronServices",
"homepage": "https://www.ironlicensing.com",
"repository": "https://git.marketally.com/IronServices/ironlicensing-mcp",
"license": "MIT",
"keywords": ["licensing", "saas", "product-management", "analytics"]
}
}
Show this to the user before installation:
Add IronLicensing?
Manage IronLicensing products, tiers, features, licenses, and analytics
v1.0.0 by IronServices · MIT License
View on GitHub
Installation Flow: Detecting Runtimes and Executing Commands
The install array describes one or more ways to install the server. Your client should detect available runtimes, present the best option, and execute the installation command.
Installation Methods
The manifest supports these installation methods:
| Method | Package Manager | Example |
|---|---|---|
dotnet-tool |
.NET CLI | dotnet tool install -g IronLicensing.Mcp |
npm |
npm | npm install -g @anthropic/mcp-server-sqlite |
pip |
pip | pip install mcp-server-fetch |
cargo |
Cargo | cargo install mcp-server-rs |
binary |
Direct download | Download from URL template |
docker |
Docker | docker pull ghcr.io/org/mcp-server:latest |
Runtime Detection
Before showing installation options, detect which runtimes are available on the user's system:
const runtimes = {
'dotnet-tool': await commandExists('dotnet'),
'npm': await commandExists('npm'),
'pip': await commandExists('pip') || await commandExists('pip3'),
'cargo': await commandExists('cargo'),
'docker': await commandExists('docker'),
};
function commandExists(cmd: string): Promise<boolean> {
return new Promise((resolve) => {
exec(`${cmd} --version`, (error) => resolve(!error));
});
}
Selecting the Best Installation Method
Filter the install array to available runtimes, then sort by priority (lower = preferred):
const availableInstalls = manifest.install
.filter(method => runtimes[method.method])
.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0));
if (availableInstalls.length === 0) {
throw new Error('No compatible installation method available');
}
const selectedInstall = availableInstalls[0];
Executing Installation Commands
For package managers (npm, pip, dotnet-tool, cargo):
const { method, package: pkg, source, command } = selectedInstall;
let installCmd = '';
switch (method) {
case 'dotnet-tool':
installCmd = `dotnet tool install -g ${pkg}`;
if (source) installCmd += ` --add-source ${source}`;
break;
case 'npm':
installCmd = `npm install -g ${pkg}`;
if (source) installCmd += ` --registry ${source}`;
break;
case 'pip':
installCmd = `pip install ${pkg}`;
if (source) installCmd += ` --index-url ${source}`;
break;
case 'cargo':
installCmd = `cargo install ${pkg}`;
break;
}
// Execute with user confirmation
const confirmed = await confirm(`Run: ${installCmd}`);
if (confirmed) {
await exec(installCmd);
}
Verifying Installation
After installation, verify the command is available:
const commandAvailable = await commandExists(selectedInstall.command);
if (!commandAvailable) {
throw new Error(`Installation succeeded but command "${selectedInstall.command}" not found on PATH`);
}
Handling Custom Registries
The source field specifies custom package registries:
{
"method": "dotnet-tool",
"package": "IronLicensing.Mcp",
"source": "https://git.marketally.com/api/packages/ironservices/nuget/index.json",
"command": "ironlicensing-mcp"
}
Pass the source URL to the package manager's registry flag (--add-source, --registry, --index-url).
Example: IronLicensing Installation
Manifest:
{
"install": [{
"method": "dotnet-tool",
"package": "IronLicensing.Mcp",
"source": "https://git.marketally.com/api/packages/ironservices/nuget/index.json",
"command": "ironlicensing-mcp",
"priority": 0
}]
}
Client flow:
- Detect
dotnetis available - Show: "Install IronLicensing MCP? Run:
dotnet tool install -g IronLicensing.Mcp --add-source https://..." - User confirms → execute command
- Verify
ironlicensing-mcpis on PATH → success
Interactive Configuration Prompts
After installation, prompt the user for configuration values. The config array describes each parameter with its type, requirement status, and prompt text.
Configuration Schema
Each config entry has:
{
"key": "api-key",
"description": "IronLicensing API key (sk_live_xxx) from /app/settings/api-keys",
"type": "secret",
"required": false,
"default": null,
"env_var": "IRONLICENSING_API_KEY",
"arg": "--api-key",
"prompt": "API key (or configure via add_account tool after connecting)"
}
Type-Aware UI
Render different UI elements based on type:
| Type | UI Element | Notes |
|---|---|---|
string |
Text input | Free-form text |
boolean |
Checkbox or toggle | True/false |
number |
Number input | Numeric value |
path |
File picker | Browse for file/directory |
url |
URL input | Validate URL format |
secret |
Password input | Mask characters, don't log |
Example (React):
function ConfigInput({ config, value, onChange }) {
switch (config.type) {
case 'secret':
return (
<input
type="password"
placeholder={config.prompt}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
case 'path':
return (
<div>
<input type="text" value={value} onChange={(e) => onChange(e.target.value)} />
<button onClick={() => selectFile().then(onChange)}>Browse...</button>
</div>
);
case 'boolean':
return (
<input
type="checkbox"
checked={value}
onChange={(e) => onChange(e.target.checked)}
/>
);
default:
return (
<input
type="text"
placeholder={config.prompt}
value={value}
onChange={(e) => onChange(e.target.value)}
/>
);
}
}
Resolution Order
Config values should be resolved in this priority order:
- User-provided value (via UI prompt)
- Environment variable (
env_varfield) - Default value (
defaultfield)
function resolveConfigValue(config, userInput) {
if (userInput !== undefined && userInput !== '') {
return userInput;
}
if (config.env_var && process.env[config.env_var]) {
return process.env[config.env_var];
}
return config.default;
}
Handling Required Fields
If required: true, block submission until the value is provided:
const missingRequired = manifest.config
.filter(c => c.required)
.filter(c => !resolveConfigValue(c, userInputs[c.key]));
if (missingRequired.length > 0) {
alert(`Missing required fields: ${missingRequired.map(c => c.key).join(', ')}`);
return;
}
Example: IronLicensing Configuration
Manifest:
{
"config": [
{
"key": "profile",
"description": "Named account profile from ~/.ironlicensing/config.json",
"type": "string",
"required": false,
"arg": "--profile",
"prompt": "Account profile (leave empty for default)"
},
{
"key": "api-key",
"description": "IronLicensing API key (sk_live_xxx)",
"type": "secret",
"required": false,
"env_var": "IRONLICENSING_API_KEY",
"arg": "--api-key",
"prompt": "API key (or configure via add_account tool after connecting)"
}
]
}
Client flow:
- Show text input: "Account profile (leave empty for default)" → user enters
production - Show password input: "API key (or configure via...)" → user enters
sk_live_abc123 - Collect values:
{ profile: "production", "api-key": "sk_live_abc123" } - Proceed to settings generation
Generating and Writing Client Settings
Once you have the configuration values, generate the client settings using the settings_template and write them to your client's configuration file.
Settings Template
The settings_template is a pre-built JSON object with variable placeholders:
{
"settings_template": {
"command": "ironlicensing-mcp",
"args": ["--profile", "${profile}"]
}
}
Variables use ${key} syntax where key matches a config[].key value.
Variable Substitution
Replace ${key} placeholders with resolved config values:
function substituteVariables(template: any, configValues: Record<string, any>): any {
if (typeof template === 'string') {
return template.replace(/\$\{([^}]+)\}/g, (_, key) => {
return configValues[key] ?? '';
});
}
if (Array.isArray(template)) {
return template.map(item => substituteVariables(item, configValues));
}
if (typeof template === 'object' && template !== null) {
return Object.fromEntries(
Object.entries(template).map(([k, v]) => [k, substituteVariables(v, configValues)])
);
}
return template;
}
const settings = substituteVariables(manifest.settings_template, configValues);
Handling Environment Variables
If a config value has an env_var field, you may choose to:
- Set the environment variable in the client's launch environment
- Pass as CLI argument using the
argfield - Embed in settings (if your client supports it)
Example: GitHub token via environment variable:
{
"config": [{
"key": "github-token",
"type": "secret",
"env_var": "GITHUB_TOKEN"
}],
"settings_template": {
"command": "mcp-server-github",
"args": [],
"env": {
"GITHUB_TOKEN": "${github-token}"
}
}
}
Generated settings:
{
"command": "mcp-server-github",
"args": [],
"env": {
"GITHUB_TOKEN": "ghp_abc123xyz"
}
}
Writing to Client Configuration
Most MCP clients use a JSON configuration file (e.g., ~/.claude/settings.json, ~/.cursor/mcp.json).
Example (Claude Desktop):
import fs from 'fs';
import path from 'path';
import os from 'os';
const configPath = path.join(os.homedir(), '.claude', 'settings.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
config.mcpServers = config.mcpServers || {};
config.mcpServers[manifest.server.name] = settings;
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
Scopes: Global vs. Project
The scopes field hints where the server should be configured:
global— user-wide configuration (e.g., personal tools, account management)project— per-project configuration (e.g., database access, project-specific APIs)both— reasonable in either scope
Implementation:
- For
globalscope, write to user-level config (~/.claude/settings.json) - For
projectscope, write to project-level config (.claude/mcp.jsonin workspace root) - For
both, let the user choose
Example: Complete Settings Generation
Manifest:
{
"server": { "name": "ironlicensing" },
"config": [
{ "key": "profile", "type": "string" },
{ "key": "api-key", "type": "secret", "env_var": "IRONLICENSING_API_KEY" }
],
"settings_template": {
"command": "ironlicensing-mcp",
"args": ["--profile", "${profile}"]
}
}
User input:
{ "profile": "production", "api-key": "sk_live_abc123" }
Generated settings:
{
"mcpServers": {
"ironlicensing": {
"command": "ironlicensing-mcp",
"args": ["--profile", "production"],
"env": {
"IRONLICENSING_API_KEY": "sk_live_abc123"
}
}
}
}
Written to ~/.claude/settings.json.
Verification: Testing the MCP Handshake
After writing the configuration, verify the connection by attempting an MCP handshake. This ensures the server is correctly installed, configured, and reachable.
The MCP Initialize Handshake
The MCP protocol starts with an initialize request:
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {
"name": "your-client",
"version": "1.0.0"
}
}
}
The server responds with its capabilities:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {},
"resources": {}
},
"serverInfo": {
"name": "ironlicensing-mcp",
"version": "1.0.0"
}
}
}
Verification Flow
- Spawn the server process using the generated
commandandargs - Send the
initializerequest over the configured transport (stdio, SSE, or HTTP) - Wait for the response (with timeout)
- Check for errors in the response
- Send
initializednotification to complete the handshake
Example (stdio transport, Node.js):
import { spawn } from 'child_process';
function verifyConnection(settings) {
return new Promise((resolve, reject) => {
const proc = spawn(settings.command, settings.args, {
env: { ...process.env, ...settings.env },
stdio: ['pipe', 'pipe', 'pipe']
});
let buffer = '';
proc.stdout.on('data', (data) => {
buffer += data.toString();
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (!line.trim()) continue;
try {
const msg = JSON.parse(line);
if (msg.id === 1 && msg.result) {
// Success! Send initialized notification
proc.stdin.write(JSON.stringify({
jsonrpc: '2.0',
method: 'notifications/initialized'
}) + '\n');
proc.kill();
resolve(msg.result.serverInfo);
}
} catch (err) {
reject(new Error(`Invalid JSON: ${line}`));
}
}
});
proc.stderr.on('data', (data) => {
console.error('Server stderr:', data.toString());
});
proc.on('error', reject);
// Send initialize request
proc.stdin.write(JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'your-client', version: '1.0.0' }
}
}) + '\n');
// Timeout after 10 seconds
setTimeout(() => {
proc.kill();
reject(new Error('Handshake timeout'));
}, 10000);
});
}
Displaying Verification Results
Show the user whether the connection succeeded:
Success:
✅ IronLicensing MCP connected
Server: ironlicensing-mcp v1.0.0
Tools: 12 available
Resources: 4 available
Failure:
❌ Connection failed
Command:ironlicensing-mcp --profile production
Error: Handshake timeout (10s)
[View Logs] [Retry]
Handling Transport Variations
- stdio: Spawn process, communicate via stdin/stdout (shown above)
- sse: Connect to
endpointURL, sendinitializevia POST, listen for SSE responses - streamable-http: Send
initializevia POST toendpoint, parse streaming JSON responses
Refer to the MCP specification for transport-specific details.
Error Handling and User Feedback
A manifest-aware client transforms the MCP server installation experience from a multi-step manual process into a guided, automated workflow. Excellent error handling and user feedback make the difference between "magic" and "frustration."
Common Error Scenarios
1. Discovery Failures
Manifest not found:
❌ No MCP manifest found at ironlicensing.com
Tried:
• https://ironlicensing.com/.well-known/mcp-manifest.json
• <link rel="mcp-manifest"> in HTML
This server may not support autodiscovery.
[Enter manifest URL manually]
Invalid JSON:
❌ Invalid manifest JSON
URL: https://example.com/.well-known/mcp-manifest.json
Error: Unexpected token '<' at position 0
The server may be returning HTML instead of JSON.
[View raw response]
Schema validation failure:
❌ Manifest validation failed
Missing required field: server.name
Invalid field: install[0].method (expected one of: npm, pip, dotnet-tool, cargo, binary, docker)
[View full manifest] [Report issue]
2. Installation Failures
No compatible runtime:
❌ Cannot install IronLicensing MCP
This server requires one of:
• .NET SDK (dotnet)
• Node.js (npm)
None are installed on your system.
[Install .NET] [Install Node.js]
Installation command failed:
❌ Installation failed
Command: dotnet tool install -g IronLicensing.Mcp
Exit code: 1
Error: Package 'IronLicensing.Mcp' not found in source 'https://...'
[View full output] [Try again]
Command not on PATH after installation:
⚠️ Installation succeeded but command not found
The package was installed but "ironlicensing-mcp" is not on your PATH.
Try:
1. Restart your terminal
2. Add ~/.dotnet/tools to your PATH
3. Run: export PATH="$PATH:~/.dotnet/tools"
[Retry verification]
3. Configuration Errors
Missing required field:
❌ Missing required configuration
The following fields are required:
• api-key: IronLicensing API key (sk_live_xxx)
[Go back]
Invalid value:
⚠️ Invalid URL
Field: base-url
Value: "not-a-url"
Expected a valid URL (e.g., https://api.example.com)
4. Handshake Failures
Timeout:
❌ Connection timeout
The server did not respond within 10 seconds.
Command: ironlicensing-mcp --profile production
Possible causes:
• The server is taking too long to start
• The command is waiting for input
• The configuration is incorrect
[View logs] [Edit configuration] [Retry]
Protocol error:
❌ MCP handshake failed
Server returned an error:
{
"code": -32600,
"message": "Invalid API key"
}
Check your configuration and try again.
[Edit configuration]
Best Practices for User Feedback
1. Be Specific
❌ Bad: "Installation failed"
✅ Good: "Installation failed: Package 'IronLicensing.Mcp' not found in source 'https://...'"
2. Suggest Next Steps
❌ Bad: "Command not found"
✅ Good: "Command not found. Try restarting your terminal or adding ~/.dotnet/tools to your PATH."
3. Provide Context
Show the exact command that failed:
Command: dotnet tool install -g IronLicensing.Mcp --add-source https://...
Exit code: 1
4. Make Logs Accessible
Capture stdout/stderr during installation and handshake. Provide a "View Logs" button.
5. Offer Recovery Actions
- Retry — try the same operation again
- Edit Configuration — go back to config prompts
- Manual Setup — show the JSON blob to copy-paste
- Report Issue — link to the server's repository issues page
Example: End-to-End Error Flow
User types ironlicensing.com:
- Discovery: Manifest found at
/.well-known/mcp-manifest.json✅ - Validation: Schema valid ✅
- Runtime Detection:
dotnetfound ✅ - Installation:
dotnet tool install...→ exit code 1 ❌- Show error: "Package not found in source"
- Suggest: "The package may not be published yet. [Contact author]"
- User clicks "Contact author" → opens
https://git.marketally.com/IronServices/ironlicensing-mcp/issues
Logging and Debugging
Log everything during the installation flow:
- Discovery URLs tried
- HTTP response codes
- Manifest JSON (redact secrets)
- Installation commands executed
- stdout/stderr from installation
- Handshake requests/responses
Store logs in a file (e.g., ~/.your-client/mcp-install.log) and provide a "View Logs" button in error dialogs.
Graceful Degradation
If manifest-based installation fails, fall back to manual setup:
❌ Automatic installation failed
You can still add this server manually.
1. Install the tool:
dotnet tool install -g IronLicensing.Mcp
2. Add to your settings.json:
{
"mcpServers": {
"ironlicensing": {
"command": "ironlicensing-mcp",
"args": ["--profile", "production"]
}
}
}
[Copy to clipboard] [Open settings.json]
Conclusion
Building a manifest-aware MCP client is a force multiplier for your users. By implementing autodiscovery, automated installation, interactive configuration, and robust error handling, you transform the server setup experience from a tedious manual process into a seamless, guided workflow.
The mcp-manifest.json specification is the bridge. Your client is the engineer that builds it. Ship it, and watch your users go from "paste this JSON blob" to "type a domain name" — and never look back.