Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,17 +146,24 @@ 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"]

// Environment variable formats (both supported):
env?: Record<string, string> | string[] // Object: { "KEY": "val" } or Array: ["KEY=val"]
}

interface RemoteMcpServerConfig {
type: "remote"
url: string // Remote MCP server URL (Streamable HTTP)
headers?: Record<string, string> // Optional headers, e.g. auth; supports ${VAR} expansion
}
```

### Examples
Expand Down Expand Up @@ -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)):
Expand Down
22 changes: 22 additions & 0 deletions src/__tests__/normalize-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -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', () => {
Expand Down
65 changes: 64 additions & 1 deletion src/__tests__/skill-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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'

Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
12 changes: 8 additions & 4 deletions src/skill-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,16 @@ export async function loadMcpJsonFromDir(
return parsed.mcp as Record<string, McpServerConfig>
}

// 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<string, unknown>)
const hasServerConfig = Object.values(parsed).some(
(v) =>
v &&
typeof v === 'object' &&
('command' in (v as Record<string, unknown>) ||
(v as Record<string, unknown>).type === 'remote')
)
if (hasCommandField) {
if (hasServerConfig) {
return parsed as unknown as Record<string, McpServerConfig>
}
}
Expand Down
85 changes: 75 additions & 10 deletions src/skill-mcp-manager.ts
Original file line number Diff line number Diff line change
@@ -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
}
Expand Down Expand Up @@ -96,12 +98,14 @@ export function createSkillMcpManager(): SkillMcpManager {
}
}

const createClient = async (
info: McpClientInfo,
config: McpServerConfig
): Promise<Client> => {
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` +
Expand All @@ -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<Client> => {
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' },
Expand All @@ -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` +
Expand Down
21 changes: 19 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -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"]
Expand All @@ -10,14 +10,31 @@
* 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, string> | string[]
/** @deprecated Use `env` instead */
environment?: Record<string, string> | 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<string, string>
}

/**
* Unified MCP server configuration - local (stdio) or remote (Streamable HTTP)
*/
export type McpServerConfig = LocalMcpServerConfig | RemoteMcpServerConfig

export interface NormalizedCommand {
command: string
args: string[]
Expand Down
8 changes: 8 additions & 0 deletions src/utils/env-vars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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: {} }
Expand Down