Skip to main content

Creating MCP Server Extensions

Model Context Protocol (MCP) servers allow you to add custom tools that the AI can use. Through extensions, you can package and distribute MCP servers with all necessary configuration, making it easy for others to install and use your tools.

What are MCP Servers?

MCP servers are:
  • Standalone processes that communicate via stdio (or other transports)
  • Provide tools (functions) the AI can call
  • Can expose prompts and resources
  • Run continuously during your session
  • Defined using the @modelcontextprotocol/sdk

When to Use MCP Servers

Use MCP servers when you need to:
  • Integrate External APIs: GitHub, Slack, databases, etc.
  • Add Specialized Tools: Image processing, data analysis, etc.
  • Access Resources: Files, databases, web services
  • Provide Prompts: Pre-configured AI prompts
  • Execute Operations: Deploy, test, monitor, etc.

Extension Structure

my-mcp-extension/
├── qwen-extension.json
├── package.json
├── tsconfig.json
├── server.ts              # MCP server source
└── dist/
    └── server.js          # Built server

Quick Start: Using Template

Create from the built-in MCP server template:
qwen extensions new my-mcp-extension mcp-server
cd my-mcp-extension
npm install
npm run build
qwen extensions link .

Manual Setup

Step 1: Create Package Files

package.json:
{
  "name": "my-mcp-extension",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "build": "tsc"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.11.0",
    "zod": "^3.22.4"
  },
  "devDependencies": {
    "@types/node": "^20.11.25",
    "typescript": "~5.4.5"
  }
}
tsconfig.json:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist"
  },
  "include": ["server.ts"]
}

Step 2: Create MCP Server

server.ts:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';

const server = new McpServer({
  name: 'my-mcp-extension',
  version: '1.0.0',
});

// Register tools here (see examples below)

const transport = new StdioServerTransport();
await server.connect(transport);

Step 3: Configure Extension

qwen-extension.json:
{
  "name": "my-mcp-extension",
  "version": "1.0.0",
  "mcpServers": {
    "myServer": {
      "command": "node",
      "args": ["${extensionPath}${/}dist${/}server.js"],
      "cwd": "${extensionPath}"
    }
  }
}

Step 4: Build and Test

npm install
npm run build
qwen extensions link .
Restart Qwen Code to load the extension.

Registering Tools

Simple Tool

server.registerTool(
  'get_greeting',
  {
    description: 'Returns a greeting message',
    inputSchema: z.object({
      name: z.string().describe('Name to greet'),
    }).shape,
  },
  async ({ name }) => {
    return {
      content: [
        {
          type: 'text',
          text: `Hello, ${name}!`,
        },
      ],
    };
  },
);

Tool with Multiple Parameters

server.registerTool(
  'search_users',
  {
    description: 'Search for users by various criteria',
    inputSchema: z.object({
      query: z.string().describe('Search query'),
      status: z.enum(['active', 'inactive', 'all'])
        .optional()
        .describe('Filter by status'),
      limit: z.number()
        .min(1)
        .max(100)
        .optional()
        .describe('Maximum results to return'),
    }).shape,
  },
  async ({ query, status = 'all', limit = 10 }) => {
    // Implement search logic
    const results = await searchUsers(query, status, limit);
    
    return {
      content: [
        {
          type: 'text',
          text: JSON.stringify(results, null, 2),
        },
      ],
    };
  },
);

Tool with API Call

server.registerTool(
  'fetch_posts',
  {
    description: 'Fetches posts from a public API',
    inputSchema: z.object({}).shape,
  },
  async () => {
    const response = await fetch(
      'https://jsonplaceholder.typicode.com/posts'
    );
    const posts = await response.json();
    
    return {
      content: [
        {
          type: 'text',
          text: JSON.stringify(posts.slice(0, 5), null, 2),
        },
      ],
    };
  },
);

Tool with Error Handling

server.registerTool(
  'divide_numbers',
  {
    description: 'Divides two numbers',
    inputSchema: z.object({
      dividend: z.number().describe('Number to divide'),
      divisor: z.number().describe('Number to divide by'),
    }).shape,
  },
  async ({ dividend, divisor }) => {
    if (divisor === 0) {
      return {
        content: [
          {
            type: 'text',
            text: 'Error: Cannot divide by zero',
          },
        ],
        isError: true,
      };
    }
    
    const result = dividend / divisor;
    return {
      content: [
        {
          type: 'text',
          text: `Result: ${result}`,
        },
      ],
    };
  },
);

Registering Prompts

Prompts are pre-configured AI prompts that users can invoke:
server.registerPrompt(
  'code-reviewer',
  {
    title: 'Code Review',
    description: 'Reviews code for quality and issues',
    argsSchema: {
      code: z.string().describe('Code to review'),
      language: z.string().optional().describe('Programming language'),
    },
  },
  ({ code, language }) => ({
    messages: [
      {
        role: 'user',
        content: {
          type: 'text',
          text: `Review this ${language || 'code'}:\n\n${code}\n\nProvide feedback on quality, potential issues, and improvements.`,
        },
      },
    ],
  }),
);

Using Environment Variables

For API keys and sensitive data:

Define Settings

qwen-extension.json:
{
  "name": "my-mcp-extension",
  "version": "1.0.0",
  "settings": [
    {
      "name": "API Key",
      "description": "Your API key for the service",
      "envVar": "MY_SERVICE_API_KEY",
      "sensitive": true
    },
    {
      "name": "Base URL",
      "description": "API base URL",
      "envVar": "MY_SERVICE_BASE_URL",
      "sensitive": false
    }
  ],
  "mcpServers": {
    "myServer": {
      "command": "node",
      "args": ["${extensionPath}${/}dist${/}server.js"],
      "cwd": "${extensionPath}",
      "env": {
        "API_KEY": "${MY_SERVICE_API_KEY}",
        "BASE_URL": "${MY_SERVICE_BASE_URL}"
      }
    }
  }
}

Access in Server

const API_KEY = process.env.API_KEY;
const BASE_URL = process.env.BASE_URL || 'https://api.example.com';

server.registerTool(
  'api_call',
  {
    description: 'Calls the external API',
    inputSchema: z.object({
      endpoint: z.string(),
    }).shape,
  },
  async ({ endpoint }) => {
    const response = await fetch(`${BASE_URL}${endpoint}`, {
      headers: {
        'Authorization': `Bearer ${API_KEY}`,
      },
    });
    
    const data = await response.json();
    return {
      content: [{ type: 'text', text: JSON.stringify(data) }],
    };
  },
);

Example: GitHub MCP Server

A complete example integrating with GitHub API:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { Octokit } from '@octokit/rest';

const server = new McpServer({
  name: 'github-tools',
  version: '1.0.0',
});

const octokit = new Octokit({
  auth: process.env.GITHUB_TOKEN,
});

server.registerTool(
  'list_repos',
  {
    description: 'List repositories for a user or organization',
    inputSchema: z.object({
      owner: z.string().describe('Username or organization'),
      type: z.enum(['all', 'owner', 'public', 'private'])
        .optional()
        .describe('Repository type'),
    }).shape,
  },
  async ({ owner, type = 'all' }) => {
    const { data } = await octokit.repos.listForUser({
      username: owner,
      type,
    });
    
    return {
      content: [
        {
          type: 'text',
          text: JSON.stringify(
            data.map(repo => ({
              name: repo.name,
              description: repo.description,
              stars: repo.stargazers_count,
              language: repo.language,
            })),
            null,
            2
          ),
        },
      ],
    };
  },
);

server.registerTool(
  'create_issue',
  {
    description: 'Create a new issue in a repository',
    inputSchema: z.object({
      owner: z.string().describe('Repository owner'),
      repo: z.string().describe('Repository name'),
      title: z.string().describe('Issue title'),
      body: z.string().optional().describe('Issue description'),
      labels: z.array(z.string()).optional().describe('Labels to add'),
    }).shape,
  },
  async ({ owner, repo, title, body, labels }) => {
    const { data } = await octokit.issues.create({
      owner,
      repo,
      title,
      body,
      labels,
    });
    
    return {
      content: [
        {
          type: 'text',
          text: `Issue created: ${data.html_url}`,
        },
      ],
    };
  },
);

server.registerTool(
  'get_pr_status',
  {
    description: 'Get status of a pull request',
    inputSchema: z.object({
      owner: z.string(),
      repo: z.string(),
      pull_number: z.number(),
    }).shape,
  },
  async ({ owner, repo, pull_number }) => {
    const { data } = await octokit.pulls.get({
      owner,
      repo,
      pull_number,
    });
    
    return {
      content: [
        {
          type: 'text',
          text: JSON.stringify({
            title: data.title,
            state: data.state,
            mergeable: data.mergeable,
            merged: data.merged,
            comments: data.comments,
            commits: data.commits,
          }, null, 2),
        },
      ],
    };
  },
);

const transport = new StdioServerTransport();
await server.connect(transport);
package.json (add dependency):
{
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.11.0",
    "@octokit/rest": "^20.0.0",
    "zod": "^3.22.4"
  }
}
qwen-extension.json:
{
  "name": "github-tools",
  "version": "1.0.0",
  "settings": [
    {
      "name": "GitHub Token",
      "description": "Personal access token for GitHub API",
      "envVar": "GITHUB_TOKEN",
      "sensitive": true
    }
  ],
  "mcpServers": {
    "github": {
      "command": "node",
      "args": ["${extensionPath}${/}dist${/}server.js"],
      "cwd": "${extensionPath}",
      "env": {
        "GITHUB_TOKEN": "${GITHUB_TOKEN}"
      }
    }
  }
}

Best Practices

1. Clear Tool Descriptions

// Good
description: 'Creates a new GitHub issue with title, body, and labels'

// Bad
description: 'Creates issue'

2. Validate Input

inputSchema: z.object({
  email: z.string().email().describe('Valid email address'),
  age: z.number().min(0).max(150).describe('Age in years'),
  status: z.enum(['active', 'inactive']).describe('Account status'),
}).shape,

3. Handle Errors Gracefully

async ({ param }) => {
  try {
    const result = await riskyOperation(param);
    return {
      content: [{ type: 'text', text: result }],
    };
  } catch (error) {
    return {
      content: [{
        type: 'text',
        text: `Error: ${error.message}`,
      }],
      isError: true,
    };
  }
}

4. Format Output Clearly

// Good - structured JSON
return {
  content: [{
    type: 'text',
    text: JSON.stringify({
      status: 'success',
      data: results,
      count: results.length,
    }, null, 2),
  }],
};

// Avoid - raw dumps
return {
  content: [{ type: 'text', text: results.toString() }],
};

5. Document Parameter Purpose

inputSchema: z.object({
  query: z.string()
    .describe('Search query - supports wildcards (*) and AND/OR operators'),
  limit: z.number()
    .min(1)
    .max(100)
    .optional()
    .describe('Max results (default: 10, max: 100)'),
}).shape,

MCP Server Configuration

Advanced configuration options:
{
  "mcpServers": {
    "myServer": {
      "command": "node",
      "args": ["${extensionPath}${/}dist${/}server.js"],
      "cwd": "${extensionPath}",
      "env": {
        "NODE_ENV": "production",
        "LOG_LEVEL": "info"
      },
      "timeout": 30000,
      "description": "My custom MCP server",
      "includeTools": ["tool1", "tool2"],
      "excludeTools": ["admin_tool"]
    }
  }
}

Debugging

Add logging to your MCP server:
const DEBUG = process.env.DEBUG === 'true';

function log(...args: any[]) {
  if (DEBUG) {
    console.error('[MCP Server]', ...args);
  }
}

server.registerTool('my_tool', { ... }, async (params) => {
  log('Tool called with params:', params);
  try {
    const result = await operation(params);
    log('Tool result:', result);
    return result;
  } catch (error) {
    log('Tool error:', error);
    throw error;
  }
});
Enable with:
"env": {
  "DEBUG": "true"
}

Next Steps