Ch.8 — Skills & Plugin System — 技能與外掛
為什麼需要技能與外掛?
Claude Code 的核心提供了「通用」的工程代理能力。但真實世界的開發者有高度特化的需求:
- 前端團隊需要元件生成技能
- DevOps 團隊需要 Terraform 部署技能
- 資料團隊需要 SQL 查詢技能
Skills 讓使用者定義可重複使用的「專家提示」。Plugins 和 MCP 則讓外部工具和服務無縫接入 Claude Code 的生態系統。
Skills System
Skill 定義格式
Skill 是一個 Markdown 檔案,用 frontmatter 定義元資料:
---name: review-prdescription: Pre-landing PR review with security focusmodel: opusshell: bashenv: REVIEW_MODE: strict---
# PR Review Skill
You are a senior code reviewer. Analyze the current PR for:
1. **Security vulnerabilities** — OWASP top 102. **Performance issues** — N+1 queries, memory leaks3. **Code style** — Project conventions from CLAUDE.md
Use `gh pr diff` to get the diff, then review file by file.Skill 發現機制
Skills 從多個來源載入,按優先順序:
const SKILL_SOURCES = [ '.claude/skills/', // 專案級 '~/.claude/skills/', // 使用者級 'managed/.claude/skills/', // 企業管理 'bundled/skills/', // 內建 'mcp://prompts', // MCP 伺服器提供];SkillTool 執行
當使用者輸入 /review-pr 時:
SkillTool解析技能名稱- 讀取 markdown 檔案,解析 frontmatter
- 將技能內容注入為新的對話訊息
- 遞迴呼叫
query()(代理主迴圈)執行技能
// src/tools/SkillTool/SkillTool.ts — 簡化版async function executeSkill(skillName: string, args: string) { const skill = resolveSkill(skillName); const prompt = skill.content.replace('${ARGS}', args);
// 作為新的 query 執行 yield* query({ model: skill.model || defaultModel, messages: [{ role: 'user', content: prompt }], // ... 繼承父代理的 tools 和 permissions });}MCP(Model Context Protocol)
MCP 是一個開放協議,讓外部服務向 AI 代理提供工具、資源和提示。Claude Code 是 MCP 的一等公民客戶端。
MCP Transport 類型
graph LR
CC[Claude Code] --> T1[Stdio Transport]
CC --> T2[SSE Transport]
CC --> T3[WebSocket Transport]
CC --> T4[InProcess Transport]
T1 --> S1[本地程序]
T2 --> S2[HTTP 伺服器]
T3 --> S3[WebSocket 伺服器]
T4 --> S4[同程序模組]
style CC fill:#6366f1,color:#fff
| Transport | 適用場景 | 特點 |
|---|---|---|
| Stdio | 本地 CLI 工具 | 透過 stdin/stdout 通訊 |
| SSE | 遠端 HTTP 服務 | Server-Sent Events 串流 |
| WebSocket | 即時雙向通訊 | 支援 mTLS |
| InProcess | 內建 MCP 伺服器 | 零延遲,同程序 |
MCP 連線生命週期
// src/services/mcp/client.ts — 真實實現(含 memoization)export const connectToServer = memoize( async ( name: string, serverRef: ScopedMcpServerConfig, serverStats?: { totalServers: number stdioCount: number sseCount: number httpCount: number }, ): Promise<MCPServerConnection> => { let transport
// 根據伺服器類型選擇 transport if (serverRef.type === 'sse') { const authProvider = new ClaudeAuthProvider(name, serverRef) const combinedHeaders = await getMcpServerHeaders(name, serverRef) transport = new SSEClientTransport({ authProvider, // SSE 長連線使用獨立的無超時 fetch fetch: wrapFetchWithTimeout( wrapFetchWithStepUpDetection(createFetchWithInit(), authProvider), ), requestInit: { headers: { 'User-Agent': getMCPUserAgent(), ...combinedHeaders, }, }, }) } else if (serverRef.type === 'stdio') { // stdio: 透過 stdin/stdout 與本地程序通訊 transport = new StdioClientTransport(/* ... */) } else if (serverRef.type === 'http') { // HTTP Streamable: 新的 MCP transport 標準 transport = new StreamableHTTPClientTransport(/* ... */) }
const client = new Client({ name, version: '1.0.0' }) await client.connect(transport) return { type: 'connected', name, client, config: serverRef, transport } },)MCP 工具呼叫的 URL Elicitation 重試
MCP 規範定義了 -32042 錯誤碼(UrlElicitationRequired),當伺服器需要使用者授權 URL 時觸發:
// src/services/mcp/client.ts — callMCPToolWithUrlElicitationRetryexport async function callMCPToolWithUrlElicitationRetry({ client, tool, args, signal, handleElicitation,}): Promise<MCPToolCallResult> { const MAX_URL_ELICITATION_RETRIES = 3 for (let attempt = 0; ; attempt++) { try { return await callToolFn({ client, tool, args, signal }) } catch (error) { if (!(error instanceof McpError) || error.code !== ErrorCode.UrlElicitationRequired) { throw error } if (attempt >= MAX_URL_ELICITATION_RETRIES) { throw new McpToolCallError(/* 超過最大重試次數 */) } // 提取 URL 並要求使用者授權,然後重試 await handleElicitation(/* ... */) } }}MCPTool — MCP 工具包裝
MCP 伺服器提供的工具會被自動包裝為 Claude Code 原生工具:
class MCPTool implements Tool { constructor( private mcpTool: MCPToolDefinition, private client: MCPClient ) {}
get inputSchema() { // MCP JSON Schema → Zod Schema 自動轉換 return jsonSchemaToZod(this.mcpTool.inputSchema); }
async call(input, context) { const result = await this.client.callTool( this.mcpTool.name, input );
// 處理大型結果(寫入磁碟避免上下文溢出) if (result.length > MAX_RESULT_SIZE) { return truncateAndSaveToDisk(result); }
return result; }}Plugin System
Plugin 來源
type PluginSource = | 'bundled' // 預裝在 binary 中 | 'marketplace' // 透過 npm 包名解析 | 'local'; // 使用者的 .claude/plugins/ 目錄Plugin 生命週期
Discovery → Loading → Validation → Hook Registration → Active ↓ ↓ ↓ ↓ ↓ 搜尋 讀取 驗證 註冊 hooks 可用 套件 設定 相容性 和工具 運行錯誤隔離
Plugin 的一個關鍵設計是錯誤隔離:
async function loadAllPlugins(options) { const results = await Promise.allSettled( pluginConfigs.map(config => loadPlugin(config)) );
// 單一 plugin 失敗不會影響其他 plugin 或核心系統 const loaded = results.filter(r => r.status === 'fulfilled'); const errors = results.filter(r => r.status === 'rejected');
if (errors.length > 0) { logPluginErrors(errors); // 記錄但不崩潰 }
return { plugins: loaded, errors };}建立自訂 Skill 範例
以下是一個完整的自訂 Skill 範例:
---name: db-migratedescription: Generate and run database migrationsmodel: sonnet---
# Database Migration Skill
You are a database migration expert. Help the user createand execute database migrations.
## Steps
1. Ask what schema changes are needed2. Generate migration file using the project's ORM3. Review the generated SQL4. Run the migration in development5. Verify the schema change
## Rules
- Always create a reversible migration- Test rollback before committing- Follow naming convention: YYYYMMDD_description將此檔案存為 .claude/skills/db-migrate.md,然後在 Claude Code 中輸入 /db-migrate 即可使用。
Skill 如何繼承父代理上下文
一個 skill 若必須從零開始建立自己的工具清單和權限,它就什麼事也做不了。你會以為 skill 是隔離的沙箱——事實正好相反。
背景:Claude Code 的 SkillTool 有兩條執行路徑:inline 執行(直接展開 skill 提示注入父對話)和 fork 執行(透過 runAgent 啟動獨立的子代理)。兩條路徑都需要從父代理繼承關鍵上下文。
繼承機制:在 inline 路徑,SkillTool.call() 回傳的 contextModifier 函式會修改 ToolUseContext,讓後續的 query loop iteration 能夠取得 skill 指定的 allowedTools 和 model。在 fork 路徑,executeForkedSkill() 直接將父代理的 context(包含 getAppState、toolPermissionContext)傳遞給 runAgent,子代理看到的 permission context 與父代理完全相同。
什麼被繼承:
toolPermissionContext(權限規則完整傳遞——skill 看不到比父代理更多的權限)- 父代理的 tool 清單(
context.options.tools,可用allowedToolsfrontmatter 再縮減) getAppState回呼(modifiedGetAppState透過 closure 鏈結到父代理)
什麼不被繼承(可被覆寫):
- 模型選擇(skill frontmatter 的
model: opus透過resolveSkillModelOverride函式計算新模型;1m context window 標記也會保留,防止上下文視窗意外縮減)
// src/tools/SkillTool/SkillTool.ts — contextModifier(inline 路徑)contextModifier(ctx) { let modifiedContext = ctx // 將 skill 的 allowedTools 合併到 permission context if (allowedTools.length > 0) { const previousGetAppState = modifiedContext.getAppState modifiedContext = { ...modifiedContext, getAppState() { const appState = previousGetAppState() return { ...appState, toolPermissionContext: { ...appState.toolPermissionContext, alwaysAllowRules: { ...appState.toolPermissionContext.alwaysAllowRules, command: [ ...new Set([ ...(appState.toolPermissionContext.alwaysAllowRules.command || []), ...allowedTools, ]), ], }, }, } }, } } // 模型覆寫:preserves [1m] suffix to prevent context window regression if (model) { modifiedContext = { ...modifiedContext, options: { ...modifiedContext.options, mainLoopModel: resolveSkillModelOverride(model, ctx.options.mainLoopModel), }, } } return modifiedContext},代價:skill 無法取得它不應有的權限——這是安全設計。但這也意味著,一個需要特殊工具或更高權限的 skill,必須要求使用者以正確的父代理配置來執行,而不能自行提升。
MCP 工具發現:JSON Schema 直送 API
你可能以為 MCP 工具的 schema 需要轉換成 Zod 才能工作——這個假設是錯的。
問題:MCP 伺服器用 JSON Schema 描述工具輸入,但 Claude Code 內建工具使用 Zod 做型別定義與 runtime 驗證。如果強行轉換,任何不支援的 JSON Schema 關鍵字都會導致工具無法使用。
實際設計:MCPTool.ts 的 inputSchema 使用 lazySchema(() => z.object({}).passthrough()) ——這是一個直接通過的 Zod schema,不做任何型別限制。與此同時,MCP 工具的真實 JSON Schema 被儲存在 inputJSONSchema 欄位(型別 ToolInputJSONSchema),並直接傳送給 Anthropic API 作為工具描述。
// src/Tool.ts — 雙軌 schema 設計export type ToolInputJSONSchema = { [x: string]: unknown type: 'object' properties?: { [x: string]: unknown }}
interface Tool { readonly inputSchema: Input // Zod schema(MCP 工具用 passthrough) readonly inputJSONSchema?: ToolInputJSONSchema // 原始 JSON Schema(MCP 工具專用)}
// src/services/mcp/client.ts — MCP 工具包裝return { ...MCPTool, inputJSONSchema: tool.inputSchema as Tool['inputJSONSchema'], // inputSchema 繼承自 MCPTool:z.object({}).passthrough() // API 呼叫使用 inputJSONSchema,runtime 驗證使用 passthrough Zod}這個設計解決了什麼邊界問題:
- JSON Schema 的任意複雜度(
anyOf、$ref、巢狀物件)不需要任何轉換,直接送給 API - MCP 伺服器更新 schema 時,不需要重新啟動 Claude Code(schema 在工具連線時讀取,儲存於記憶體)
- Runtime 驗證使用 passthrough——如果模型生成了不符合 schema 的輸入,MCP 伺服器本身負責回傳錯誤
代價:這個設計讓 Claude Code 無法在客戶端預先驗證 MCP 工具輸入的型別正確性。不合規的輸入只有在 MCP 伺服器端才會被發現,錯誤訊息的品質完全取決於 MCP 伺服器的實作。
MCP Sampling Protocol:伺服器反向呼叫 LLM
你以為 MCP 伺服器只是被動接受呼叫的工具——實際上 MCP 規範允許伺服器主動要求 Claude 生成文字。
問題:一個 MCP 伺服器(例如程式碼審查工具)可能需要 Claude 先總結一段差異,才能套用自己的規則。但 MCP 伺服器是獨立的外部程序,沒有直接存取 LLM 的能力。
MCP Sampling 協議:MCP 規範定義了 sampling/createMessage 請求——伺服器可以透過同一條 MCP 連線,反向向客戶端(Claude Code)要求進行 LLM 呼叫。Claude Code 收到這個請求後,以自己的 API 憑證發送 Anthropic API 請求,將結果回傳給 MCP 伺服器。
這創造了一個雙向流程:
Claude → 呼叫 MCP 工具 MCP 工具 → 發送 sampling/createMessage Claude Code → 呼叫 Anthropic API Claude Code ← 收到生成結果 MCP 工具 ← 收到生成結果Claude ← 收到 MCP 工具的最終結果在 Claude Code 的原始碼中,post-sampling 鉤子機制透過 registerPostSamplingHook 和 executePostSamplingHooks 提供了類似的「模型回應後執行邏輯」能力,並在 query.ts 的主迴圈中,每次 LLM 回應完成後執行:
// src/query.ts — 模型回應後執行 post-sampling hooksif (assistantMessages.length > 0) { void executePostSamplingHooks( [...messagesForQuery, ...assistantMessages], systemPrompt, userContext, systemContext, toolUseContext, querySource, )}應用場景:一個資料庫遷移 MCP 伺服器在套用 schema 變更前,可以要求 Claude 審查遷移腳本的風險;一個文件 MCP 伺服器可以要求 Claude 將技術文件翻譯成多種語言,再儲存回文件系統。
代價:每次 MCP 伺服器發出 sampling/createMessage 請求,都會消耗 session 的 token 預算。設計不良的 MCP 伺服器可能觸發鏈式 LLM 呼叫,導致 token 消耗失控;而這些費用由 Claude Code 使用者承擔,而非 MCP 伺服器提供者。
Plugin 安全模型:擴展點的邊界
一個可以自行授予權限的外掛,就是一個安全漏洞。Claude Code 的 Plugin 系統從設計上封堵了這個攻擊面。
問題:Plugins 可以提供工具和 hooks——如果沒有清晰的邊界,惡意外掛可以宣稱自己需要任何權限,或者覆寫系統的拒絕規則。
安全設計:Plugin 提供的工具進入與內建工具完全相同的 checkPermissionsAndCallTool 管道(src/services/tools/toolExecution.ts)。沒有任何特殊快速通道——一個 MCP plugin 工具和 BashTool 走相同的 toolPermissionContext 查詢邏輯。
管理層級(階層式):
- policySettings(企業 MDM 管理設定) > 使用者設定 > 專案設定
- 透過
getManagedPluginNames()讀取的 managed plugin 清單,無法被--plugin-dirCLI 旗標覆寫(mergePluginSources函式明確保護這一點) isRestrictedToPluginOnly()和isSourceAdminTrusted()函式(pluginOnlyPolicy.ts)控制哪些客製化面向只允許來自 plugin 或 policySettings 的來源
// src/utils/plugins/pluginLoader.ts — mergePluginSources 中的管理保護// 受 policySettings 管理的外掛,不能被 --plugin-dir 覆寫if (managed?.has(sessionPlugin.name)) { errors.push({ type: 'session-overrides-managed', ... }) continue // 直接丟棄 session plugin}
// src/utils/plugins/managedPlugins.ts — 讀取企業管理的外掛名單export function getManagedPluginNames(): Set<string> | null { const enabledPlugins = getSettingsForSource('policySettings')?.enabledPlugins // 只保護 boolean 型別的 plugin@marketplace 條目(true 或 false 都受保護) // ...}Promise.allSettled 的角色:loadPluginsFromMarketplaces 使用 Promise.allSettled 並行載入所有 marketplace plugins。每個 plugin 的載入失敗(網路錯誤、schema 驗證失敗、版本衝突)都被個別捕捉,不會影響其他 plugin 或核心系統啟動。結果分類為 enabled、disabled、errors 三組回傳。
// src/utils/plugins/pluginLoader.ts — 並行載入,個別容錯const results = await Promise.allSettled( marketplacePluginEntries.map(async ([pluginId, enabledValue]) => { // 每個 plugin 獨立載入;失敗只影響自己 // ... }),)// results 中的 rejected 條目轉為 PluginError,不拋出異常代價:這個設計讓企業可以鎖定外掛生態系統,但也意味著 plugin 無法為自己申請比父 session 更高的權限——如果一個 plugin 想要執行某項需要特殊權限的操作,它必須要求使用者在正確的 permission context 下執行 Claude Code。