diff --git a/README.md b/README.md index 6d96390..048419f 100644 --- a/README.md +++ b/README.md @@ -146,10 +146,11 @@ Invokes an operation on a skill-embedded MCP server. ## Configuration Format -The MCP configuration supports multiple formats for compatibility with both OpenCode and oh-my-opencode: +The MCP configuration supports multiple formats for compatibility with both OpenCode and oh-my-opencode. Servers can be **local** (spawned via stdio) or **remote** (Streamable HTTP): ```typescript -interface McpServerConfig { +interface LocalMcpServerConfig { + type?: "local" // Optional; local is the default // Command formats (both supported): command: string | string[] // Array: ["npx", "-y", "@some/mcp"] or String: "npx" args?: string[] // Used with string command: ["-y", "@some/mcp"] @@ -157,6 +158,12 @@ interface McpServerConfig { // Environment variable formats (both supported): env?: Record | string[] // Object: { "KEY": "val" } or Array: ["KEY=val"] } + +interface RemoteMcpServerConfig { + type: "remote" + url: string // Remote MCP server URL (Streamable HTTP) + headers?: Record // Optional headers, e.g. auth; supports ${VAR} expansion +} ``` ### Examples @@ -185,6 +192,19 @@ interface McpServerConfig { } ``` +**Remote MCP server (Streamable HTTP):** +```json +{ + "my-remote-server": { + "type": "remote", + "url": "https://mcp.example.com/mcp", + "headers": { + "Authorization": "Bearer ${MY_API_TOKEN}" + } + } +} +``` + ## Example Skill Here's a complete example of a skill with an embedded MCP server (from [`.opencode/skills/playwright-example/SKILL.md`](.opencode/skills/playwright-example/SKILL.md)): diff --git a/src/__tests__/normalize-command.test.ts b/src/__tests__/normalize-command.test.ts index ac9b761..5e478c8 100644 --- a/src/__tests__/normalize-command.test.ts +++ b/src/__tests__/normalize-command.test.ts @@ -106,6 +106,17 @@ describe('normalizeCommand', () => { ) }) + it('throws error for remote MCP configs', () => { + const config: McpServerConfig = { + type: 'remote', + url: 'https://mcp.example.com/mcp' + } + + expect(() => normalizeCommand(config)).toThrow( + 'Invalid MCP command configuration: remote MCP servers do not use a command' + ) + }) + it('ignores args field when command is array (OpenCode format takes precedence)', () => { const config: McpServerConfig = { command: ['npx', '-y', '@some/package'], @@ -223,6 +234,17 @@ describe('normalizeEnv', () => { expect(result.env).toEqual({}) }) + + it('throws error for remote MCP configs', () => { + const config: McpServerConfig = { + type: 'remote', + url: 'https://mcp.example.com/mcp' + } + + expect(() => normalizeEnv(config)).toThrow( + 'Invalid MCP env configuration: remote MCP servers do not use env vars' + ) + }) }) describe('backward compatibility', () => { diff --git a/src/__tests__/skill-loader.test.ts b/src/__tests__/skill-loader.test.ts index b5eba36..11c4662 100644 --- a/src/__tests__/skill-loader.test.ts +++ b/src/__tests__/skill-loader.test.ts @@ -18,7 +18,8 @@ import { OpenCodeEmbeddedSkillMcp } from '../index.js' import { discoverOpencodeGlobalSkills, discoverOpencodeProjectSkills, - discoverSkills + discoverSkills, + loadMcpJsonFromDir } from '../skill-loader.js' function skillMarkdown(name: string, serverName: string): string { @@ -110,6 +111,68 @@ describe('skill discovery', () => { }) }) + it('discovers remote MCP servers from skill frontmatter', async () => { + const skillDir = join(mockedHome.path, '.config', 'opencode', 'skills', 'remote-skill') + await mkdir(skillDir, { recursive: true }) + await writeFile( + join(skillDir, 'SKILL.md'), + `--- +name: remote-skill +description: Test skill +mcp: + remote-server: + type: remote + url: https://mcp.example.com/mcp + headers: + Authorization: Bearer \${API_TOKEN} +--- + +# remote-skill +` + ) + + const skills = await discoverOpencodeGlobalSkills() + + expect(skills).toHaveLength(1) + expect(skills[0].mcpConfig).toMatchObject({ + 'remote-server': { + type: 'remote', + url: 'https://mcp.example.com/mcp', + headers: { Authorization: 'Bearer ${API_TOKEN}' } + } + }) + }) + + it('loads direct-format mcp.json entries with remote configs', async () => { + const skillDir = join(temporaryRoot, 'remote-json-skill') + await mkdir(skillDir, { recursive: true }) + await writeFile( + join(skillDir, 'mcp.json'), + JSON.stringify({ + 'remote-server': { type: 'remote', url: 'https://mcp.example.com/mcp' } + }) + ) + + const config = await loadMcpJsonFromDir(skillDir) + + expect(config).toEqual({ + 'remote-server': { type: 'remote', url: 'https://mcp.example.com/mcp' } + }) + }) + + it('still ignores direct-format mcp.json entries with neither command nor remote type', async () => { + const skillDir = join(temporaryRoot, 'invalid-json-skill') + await mkdir(skillDir, { recursive: true }) + await writeFile( + join(skillDir, 'mcp.json'), + JSON.stringify({ 'some-key': { unrelated: true } }) + ) + + const config = await loadMcpJsonFromDir(skillDir) + + expect(config).toBeUndefined() + }) + it('registers skill_mcp without replacing OpenCode native skill tool', async () => { process.env.OPENCODE_LAZY_LOADER_FORCE = '1' diff --git a/src/index.ts b/src/index.ts index 7f6c214..548fdfa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -132,6 +132,6 @@ export const OpenCodeEmbeddedSkillMcp: Plugin = async ({ client }) => { export default OpenCodeEmbeddedSkillMcp // Re-export types for external use -export type { LoadedSkill, McpServerConfig, SkillScope } from './types.js' +export type { LoadedSkill, McpServerConfig, LocalMcpServerConfig, RemoteMcpServerConfig, SkillScope } from './types.js' export { discoverSkills } from './skill-loader.js' export { createSkillMcpManager } from './skill-mcp-manager.js' diff --git a/src/skill-loader.ts b/src/skill-loader.ts index 63c8e6f..2bcc6fd 100644 --- a/src/skill-loader.ts +++ b/src/skill-loader.ts @@ -45,12 +45,16 @@ export async function loadMcpJsonFromDir( return parsed.mcp as Record } - // Support direct { serverName: { command: ... } } format + // Support direct { serverName: { command: ... } } or { serverName: { type: "remote", url: ... } } format if (parsed && typeof parsed === 'object' && !('mcpServers' in parsed) && !('mcp' in parsed)) { - const hasCommandField = Object.values(parsed).some( - (v) => v && typeof v === 'object' && 'command' in (v as Record) + const hasServerConfig = Object.values(parsed).some( + (v) => + v && + typeof v === 'object' && + ('command' in (v as Record) || + (v as Record).type === 'remote') ) - if (hasCommandField) { + if (hasServerConfig) { return parsed as unknown as Record } } diff --git a/src/skill-mcp-manager.ts b/src/skill-mcp-manager.ts index 1be9682..f771dd1 100644 --- a/src/skill-mcp-manager.ts +++ b/src/skill-mcp-manager.ts @@ -1,11 +1,13 @@ import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' -import type { McpClientInfo, McpContext, McpServerConfig } from './types.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' +import type { McpClientInfo, McpContext, McpServerConfig, LocalMcpServerConfig, RemoteMcpServerConfig } from './types.js' import { expandEnvVarsInObject, createCleanMcpEnvironment, normalizeCommand, normalizeEnv } from './utils/env-vars.js' interface ManagedClient { client: Client - transport: StdioClientTransport + transport: Transport skillName: string lastUsedAt: number } @@ -96,12 +98,14 @@ export function createSkillMcpManager(): SkillMcpManager { } } - const createClient = async ( - info: McpClientInfo, - config: McpServerConfig - ): Promise => { - const key = getClientKey(info) + const isRemoteConfig = (config: McpServerConfig): config is RemoteMcpServerConfig => { + return config.type === 'remote' + } + const createLocalTransport = ( + info: McpClientInfo, + config: LocalMcpServerConfig + ): StdioClientTransport => { if (!config.command) { throw new Error( `MCP server "${info.serverName}" is missing required 'command' field.\n\n` + @@ -116,14 +120,62 @@ export function createSkillMcpManager(): SkillMcpManager { const { env } = normalizeEnv(config) const mergedEnv = createCleanMcpEnvironment(env) - registerProcessCleanup() - - const transport = new StdioClientTransport({ + return new StdioClientTransport({ command, args, env: mergedEnv, stderr: 'ignore' }) + } + + const createRemoteTransport = ( + info: McpClientInfo, + config: RemoteMcpServerConfig + ): StreamableHTTPClientTransport => { + if (!config.url) { + throw new Error( + `MCP server "${info.serverName}" is missing a required "url" field.\n\n` + + `Remote servers must specify a valid HTTP or HTTPS URL.` + ) + } + + let url: URL + try { + url = new URL(config.url) + } catch { + throw new Error( + `MCP server "${info.serverName}" has an invalid URL: ${config.url}\n\n` + + `The URL must be a valid HTTP or HTTPS URL.` + ) + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new Error( + `MCP server "${info.serverName}" has an unsupported URL scheme: ${url.protocol}\n\n` + + `The URL must use the http: or https: scheme.` + ) + } + + const headers = config.headers && Object.keys(config.headers).length > 0 + ? config.headers + : undefined + + return new StreamableHTTPClientTransport(url, { + requestInit: headers ? { headers } : undefined + }) + } + + const createClient = async ( + info: McpClientInfo, + config: McpServerConfig + ): Promise => { + const key = getClientKey(info) + + registerProcessCleanup() + + const transport: Transport = isRemoteConfig(config) + ? createRemoteTransport(info, config) + : createLocalTransport(info, config) const client = new Client( { name: `skill-mcp-${info.skillName}-${info.serverName}`, version: '1.0.0' }, @@ -140,6 +192,19 @@ export function createSkillMcpManager(): SkillMcpManager { } const errorMessage = error instanceof Error ? error.message : String(error) + if (isRemoteConfig(config)) { + throw new Error( + `Failed to connect to remote MCP server "${info.serverName}".\n\n` + + `URL: ${config.url}\n` + + `Reason: ${errorMessage}\n\n` + + `Hints:\n` + + ` - Verify the server URL is correct and reachable\n` + + ` - Check if authentication headers are required\n` + + ` - Ensure the remote server supports MCP Streamable HTTP transport` + ) + } + + const { command, args } = normalizeCommand(config) throw new Error( `Failed to connect to MCP server "${info.serverName}".\n\n` + `Command: ${command} ${args.join(' ')}\n` + diff --git a/src/types.ts b/src/types.ts index 8d041e1..e9ee246 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ /** - * Configuration for an MCP server + * Configuration for a local (stdio) MCP server * * Command formats: * 1. Array format: command: ["npx", "-y", "@some/mcp-server"] @@ -10,7 +10,8 @@ * 2. Array format (OpenCode): env: ["KEY=value"] * 3. Legacy field name: environment (same formats as env) */ -export interface McpServerConfig { +export interface LocalMcpServerConfig { + type?: 'local' command?: string | string[] args?: string[] env?: Record | string[] @@ -18,6 +19,22 @@ export interface McpServerConfig { environment?: Record | string[] } +/** + * Configuration for a remote MCP server (Streamable HTTP transport) + */ +export interface RemoteMcpServerConfig { + type: 'remote' + /** Remote MCP server URL (http or https) */ + url: string + /** Custom headers to send with requests; values support ${VAR} expansion */ + headers?: Record +} + +/** + * Unified MCP server configuration - local (stdio) or remote (Streamable HTTP) + */ +export type McpServerConfig = LocalMcpServerConfig | RemoteMcpServerConfig + export interface NormalizedCommand { command: string args: string[] diff --git a/src/utils/env-vars.ts b/src/utils/env-vars.ts index efc93f5..3bf6cb2 100644 --- a/src/utils/env-vars.ts +++ b/src/utils/env-vars.ts @@ -77,6 +77,10 @@ export function createCleanMcpEnvironment( } export function normalizeCommand(config: McpServerConfig): NormalizedCommand { + if (config.type === 'remote') { + throw new Error('Invalid MCP command configuration: remote MCP servers do not use a command') + } + if (Array.isArray(config.command)) { if (config.command.length === 0) { throw new Error('Invalid MCP command configuration: command array must not be empty') @@ -96,6 +100,10 @@ export function normalizeCommand(config: McpServerConfig): NormalizedCommand { } export function normalizeEnv(config: McpServerConfig): NormalizedEnv { + if (config.type === 'remote') { + throw new Error('Invalid MCP env configuration: remote MCP servers do not use env vars') + } + const envConfig = config.env ?? config.environment if (!envConfig) { return { env: {} }