diff --git a/.opencode/skill/playwright-example/SKILL.md b/.opencode/skills/playwright-example/SKILL.md similarity index 100% rename from .opencode/skill/playwright-example/SKILL.md rename to .opencode/skills/playwright-example/SKILL.md diff --git a/AGENTS.md b/AGENTS.md index b177ca6..084e6a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,8 +185,8 @@ Set `OPENCODE_LAZY_LOADER_FORCE=1` to force-enable the plugin even when oh-my-op | Priority | Location | Scope | |----------|----------|-------| -| 1 (highest) | `.opencode/skill/` | Project-specific | -| 2 | `~/.config/opencode/skill/` | User global | +| 1 (highest) | `.opencode/skills/` | Project-specific | +| 2 | `~/.config/opencode/skills/` | User global | Project skills override global skills with the same name. @@ -217,7 +217,7 @@ Before releasing, verify: | OpenCode hangs on startup | Missing `dist/` in npm package | Run `npm run build` before publish | | `ERR_MODULE_NOT_FOUND` | Package published without build | Ensure `prepack` script exists | | MCP connection fails | Command not found | Check PATH, ensure package installed | -| Skills not discovered | Wrong directory | Check `.opencode/skill/` or `~/.config/opencode/skill/` | +| Skills not discovered | Wrong directory | Check `.opencode/skills/` or `~/.config/opencode/skills/` | | Env vars not expanded | Wrong syntax | Use `${VAR}` not `$VAR` | ## Contributing diff --git a/README.md b/README.md index 8c6abb1..2e628a6 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ This is the OpenCode plugin that lazy-loads skill-embedded MCP servers. It lets skills bundle their own MCP servers so they can be loaded on-demand instead of being configured globally. -Note: This package was renamed from `opencode-embedded-skill-mcp` to `opencode-lazy-loader`. If you still have the old package, upgrade to the new name. - This is a standalone OpenCode plugin that enables skills to bundle and manage their own [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers, then lazy-load them on demand. This allows skills to bring their own tools, resources, and prompts without requiring manual server configuration in `opencode.json`. @@ -30,13 +28,11 @@ This allows skills to bring their own tools, resources, and prompts without requ ## Installation -Deprecation notice: The package was renamed from `opencode-embedded-skill-mcp` to `opencode-lazy-loader`. If you installed the old name, update to the new package. - Add the plugin to your `opencode.json`: ```json { - "plugin": ["opencode-lazy-loader"] + "plugin": ["@orionpax/opencode-lazy-mcp"] } ``` @@ -44,7 +40,7 @@ Or install it locally: ```json { - "plugin": ["./path/to/opencode-lazy-loader"] + "plugin": ["./path/to/@orionpax/opencode-lazy-mcp"] } ``` @@ -62,7 +58,7 @@ Then use the embedded MCP: skill_mcp(mcp_name="playwright", tool_name="browser_navigate", arguments='{"url": "https://example.com"}') ``` -See [`.opencode/skill/playwright-example/SKILL.md`](.opencode/skill/playwright-example/SKILL.md) for the full example. +See [`.opencode/skills/playwright-example/SKILL.md`](.opencode/skills/playwright-example/SKILL.md) for the full example. ## Usage @@ -70,7 +66,7 @@ See [`.opencode/skill/playwright-example/SKILL.md`](.opencode/skill/playwright-e You can define MCP servers in the skill's YAML frontmatter: -**`~/.config/opencode/skill/my-skill/SKILL.md`** +**`~/.config/opencode/skills/my-skill/SKILL.md`** ```markdown --- @@ -88,7 +84,7 @@ This skill provides browser automation tools via the `playwright` MCP. Alternatively, place an `mcp.json` file in the skill directory: -**`~/.config/opencode/skill/browser-automation/mcp.json`** +**`~/.config/opencode/skills/browser-automation/mcp.json`** ```json { @@ -184,7 +180,7 @@ interface McpServerConfig { ## Example Skill -Here's a complete example of a skill with an embedded MCP server (from [`.opencode/skill/playwright-example/SKILL.md`](.opencode/skill/playwright-example/SKILL.md)): +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)): ```markdown --- diff --git a/package-lock.json b/package-lock.json index 63b54e7..ce5d480 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-lazy-loader", - "version": "1.0.1", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-lazy-loader", - "version": "1.0.1", + "version": "1.0.3", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", @@ -943,6 +943,7 @@ "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1454,6 +1455,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -1989,6 +1991,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2458,6 +2461,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2648,6 +2652,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 2fad5cc..d27707c 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "opencode-lazy-loader", + "name": "@orionpax/opencode-lazy-mcp", "version": "1.0.3", - "description": "OpenCode plugin for lazy-loading skill-embedded MCP servers", + "description": "OpenCode plugin for skill-embedded MCP support - skills can define their own MCP servers that are automatically discovered and lazily loaded", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -19,8 +19,7 @@ "watch": "npx tsc --watch", "clean": "rm -rf dist", "test": "vitest run", - "test:watch": "vitest", - "prepack": "npm run clean && npm run build" + "test:watch": "vitest" }, "keywords": [ "opencode", @@ -28,11 +27,11 @@ "mcp", "skill" ], - "author": "keybrdist", + "author": "orionpax", "license": "MIT", "repository": { "type": "git", - "url": "git+https://github.com/keybrdist/opencode-lazy-loader.git" + "url": "git+https://github.com/orionpax/opencode-lazy-mcp.git" }, "files": [ "dist", diff --git a/src/index.ts b/src/index.ts index 23ff359..8bb7388 100644 --- a/src/index.ts +++ b/src/index.ts @@ -138,6 +138,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 2117e1e..2c96ea6 100644 --- a/src/skill-loader.ts +++ b/src/skill-loader.ts @@ -1,4 +1,4 @@ -import { promises as fs } from 'fs' +import { promises as fs, Dirent } from 'fs' import { join, basename } from 'path' import { homedir } from 'os' import type { LoadedSkill, McpServerConfig, SkillScope, LazyContent } from './types.js' @@ -45,12 +45,15 @@ 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) ) - if (hasCommandField) { + const hasRemoteConfig = Object.values(parsed).some( + (v) => v && typeof v === 'object' && 'type' in (v as Record) && (v as Record).type === 'remote' + ) + if (hasCommandField || hasRemoteConfig) { return parsed as unknown as Record } } @@ -132,7 +135,7 @@ export async function loadSkillsFromDir( skillsDir: string, scope: SkillScope ): Promise { - const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => []) + const entries: Dirent[] = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => []) const skills: LoadedSkill[] = [] for (const entry of entries) { @@ -191,18 +194,18 @@ export async function loadSkillsFromDir( } /** - * Discover skills from opencode global directory (~/.config/opencode/skill/) + * Discover skills from opencode global directory (~/.config/opencode/skills/) */ export async function discoverOpencodeGlobalSkills(): Promise { - const opencodeSkillsDir = join(homedir(), '.config', 'opencode', 'skill') + const opencodeSkillsDir = join(homedir(), '.config', 'opencode', 'skills') return loadSkillsFromDir(opencodeSkillsDir, 'opencode') } /** - * Discover skills from opencode project directory (.opencode/skill/) + * Discover skills from opencode project directory (.opencode/skills/) */ export async function discoverOpencodeProjectSkills(): Promise { - const opencodeProjectDir = join(process.cwd(), '.opencode', 'skill') + const opencodeProjectDir = join(process.cwd(), '.opencode', 'skills') return loadSkillsFromDir(opencodeProjectDir, 'opencode-project') } diff --git a/src/skill-mcp-manager.ts b/src/skill-mcp-manager.ts index 1be9682..c70f6ef 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, RemoteMcpServerConfig, LocalMcpServerConfig } 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,40 +98,30 @@ export function createSkillMcpManager(): SkillMcpManager { } } + const isRemoteConfig = (config: McpServerConfig): config is RemoteMcpServerConfig => { + return config.type === 'remote' + } + const createClient = async ( info: McpClientInfo, config: McpServerConfig ): Promise => { const key = getClientKey(info) - - if (!config.command) { - throw new Error( - `MCP server "${info.serverName}" is missing required 'command' field.\n\n` + - `The MCP configuration in skill "${info.skillName}" must specify a command to execute.\n\n` + - `Supported formats:\n` + - ` Format A (array): command: ["npx", "-y", "@some/mcp-server"]\n` + - ` Format B (string): command: "npx", args: ["-y", "@some/mcp-server"]` - ) - } - - const { command, args } = normalizeCommand(config) - const { env } = normalizeEnv(config) - const mergedEnv = createCleanMcpEnvironment(env) - registerProcessCleanup() - const transport = new StdioClientTransport({ - command, - args, - env: mergedEnv, - stderr: 'ignore' - }) - const client = new Client( { name: `skill-mcp-${info.skillName}-${info.serverName}`, version: '1.0.0' }, { capabilities: {} } ) + let transport: Transport + + if (isRemoteConfig(config)) { + transport = await createRemoteTransport(info, config, client) + } else { + transport = await createLocalTransport(info, config) + } + try { await client.connect(transport) } catch (error) { @@ -140,15 +132,28 @@ export function createSkillMcpManager(): SkillMcpManager { } const errorMessage = error instanceof Error ? error.message : String(error) - throw new Error( - `Failed to connect to MCP server "${info.serverName}".\n\n` + - `Command: ${command} ${args.join(' ')}\n` + - `Reason: ${errorMessage}\n\n` + - `Hints:\n` + - ` - Ensure the command is installed and available in PATH\n` + - ` - Check if the MCP server package exists\n` + - ` - Verify the args are correct for this server` - ) + 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 or OAuth are required\n` + + ` - Ensure the remote server supports MCP Streamable HTTP transport` + ) + } else { + const cmd = normalizeCommand(config) + throw new Error( + `Failed to connect to MCP server "${info.serverName}".\n\n` + + `Command: ${cmd.command} ${cmd.args.join(' ')}\n` + + `Reason: ${errorMessage}\n\n` + + `Hints:\n` + + ` - Ensure the command is installed and available in PATH\n` + + ` - Check if the MCP server package exists\n` + + ` - Verify the args are correct for this server` + ) + } } clients.set(key, { @@ -163,6 +168,57 @@ export function createSkillMcpManager(): SkillMcpManager { return client } + const createLocalTransport = async ( + info: McpClientInfo, + config: LocalMcpServerConfig + ): Promise => { + if (!config.command) { + throw new Error( + `MCP server "${info.serverName}" is missing required 'command' field.\n\n` + + `The MCP configuration in skill "${info.skillName}" must specify a command to execute.\n\n` + + `Supported formats:\n` + + ` Format A (array): command: ["npx", "-y", "@some/mcp-server"]\n` + + ` Format B (string): command: "npx", args: ["-y", "@some/mcp-server"]` + ) + } + + const { command, args } = normalizeCommand(config) + const { env } = normalizeEnv(config) + const mergedEnv = createCleanMcpEnvironment(env) + + return new StdioClientTransport({ + command, + args, + env: mergedEnv, + stderr: 'ignore' + }) + } + + const createRemoteTransport = async ( + info: McpClientInfo, + config: RemoteMcpServerConfig, + _client: Client + ): Promise => { + 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.` + ) + } + + const requestInit: RequestInit = {} + if (config.headers && Object.keys(config.headers).length > 0) { + requestInit.headers = expandEnvVarsInObject(config.headers) + } + + return new StreamableHTTPClientTransport(url, { + requestInit: Object.keys(requestInit).length > 0 ? requestInit : undefined + }) + } + const getOrCreateClient = async ( info: McpClientInfo, config: McpServerConfig diff --git a/src/types.ts b/src/types.ts index e753c00..8331d88 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ /** - * Configuration for an MCP server + * Configuration for a local 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,28 @@ export interface McpServerConfig { environment?: Record | string[] } +/** + * Configuration for a remote MCP server + */ +export interface RemoteMcpServerConfig { + type: 'remote' + /** Remote MCP server URL */ + url: string + /** Custom headers to send with requests */ + headers?: Record + /** OAuth configuration, or false to disable OAuth */ + oauth?: { + clientId?: string + clientSecret?: string + scope?: string + } | false +} + +/** + * Unified MCP server configuration - local or remote + */ +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..4865650 100644 --- a/src/utils/env-vars.ts +++ b/src/utils/env-vars.ts @@ -1,4 +1,4 @@ -import type { McpServerConfig, NormalizedCommand, NormalizedEnv } from '../types.js' +import type { LocalMcpServerConfig, NormalizedCommand, NormalizedEnv } from '../types.js' /** * Expand environment variables in a string @@ -76,7 +76,7 @@ export function createCleanMcpEnvironment( return baseEnv } -export function normalizeCommand(config: McpServerConfig): NormalizedCommand { +export function normalizeCommand(config: LocalMcpServerConfig): NormalizedCommand { if (Array.isArray(config.command)) { if (config.command.length === 0) { throw new Error('Invalid MCP command configuration: command array must not be empty') @@ -95,7 +95,7 @@ export function normalizeCommand(config: McpServerConfig): NormalizedCommand { throw new Error('Invalid MCP command configuration: command must be a string or array') } -export function normalizeEnv(config: McpServerConfig): NormalizedEnv { +export function normalizeEnv(config: LocalMcpServerConfig): NormalizedEnv { const envConfig = config.env ?? config.environment if (!envConfig) { return { env: {} }