feat: add static repository plugin format
This commit is contained in:
109
packages/cordis/repository-plugin/tests/mcp-format.spec.ts
Normal file
109
packages/cordis/repository-plugin/tests/mcp-format.spec.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseMcpDocument, resolveMcpServers } from '../src/mcp.ts'
|
||||
|
||||
describe('repository plugin common .mcp.json support', () => {
|
||||
it('maps Expo-style HTTP servers to the existing Streamable HTTP client config', () => {
|
||||
const document = parseMcpDocument(JSON.stringify({
|
||||
mcpServers: {
|
||||
expo: { type: 'http', url: 'https://mcp.expo.dev/mcp' },
|
||||
},
|
||||
}))
|
||||
|
||||
expect(resolveMcpServers(document, {}, '/plugin')).toEqual([{
|
||||
transport: 'streamable-http',
|
||||
serverName: 'expo',
|
||||
url: 'https://mcp.expo.dev/mcp',
|
||||
headers: {},
|
||||
}])
|
||||
})
|
||||
|
||||
it('maps DataJunction-style stdio servers and expands exact environment placeholders', () => {
|
||||
const document = parseMcpDocument(JSON.stringify({
|
||||
mcpServers: {
|
||||
datajunction: {
|
||||
command: 'dj-mcp',
|
||||
args: ['--endpoint', '${DJ_API_URL}'],
|
||||
env: { DJ_API_URL: '${DJ_API_URL}' },
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
expect(resolveMcpServers(document, { DJ_API_URL: 'http://localhost:8000' }, '/plugin')).toEqual([{
|
||||
transport: 'stdio',
|
||||
serverName: 'datajunction',
|
||||
command: 'dj-mcp',
|
||||
args: ['--endpoint', 'http://localhost:8000'],
|
||||
env: { DJ_API_URL: 'http://localhost:8000' },
|
||||
cwd: '/plugin',
|
||||
}])
|
||||
})
|
||||
|
||||
it('fails loud when a declared environment value is absent', () => {
|
||||
const document = parseMcpDocument(JSON.stringify({
|
||||
mcpServers: { datajunction: { command: 'dj-mcp', env: { DJ_API_URL: '${DJ_API_URL}' } } },
|
||||
}))
|
||||
|
||||
expect(() => resolveMcpServers(document, {}, '/plugin')).toThrow('missing environment variable DJ_API_URL')
|
||||
})
|
||||
|
||||
it('accepts explicit stdio defaults and expands HTTP URLs and headers', () => {
|
||||
const document = parseMcpDocument(JSON.stringify({
|
||||
mcpServers: {
|
||||
local: { type: 'stdio', command: 'local-mcp' },
|
||||
remote: {
|
||||
type: 'http',
|
||||
url: 'http://${MCP_HOST}/mcp',
|
||||
headers: { Authorization: 'Bearer ${MCP_TOKEN}' },
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
expect(resolveMcpServers(document, { MCP_HOST: 'localhost:3000', MCP_TOKEN: 'test-token' }, '/plugin')).toEqual([
|
||||
{
|
||||
transport: 'stdio',
|
||||
serverName: 'local',
|
||||
command: 'local-mcp',
|
||||
args: [],
|
||||
env: {},
|
||||
cwd: '/plugin',
|
||||
},
|
||||
{
|
||||
transport: 'streamable-http',
|
||||
serverName: 'remote',
|
||||
url: 'http://localhost:3000/mcp',
|
||||
headers: { Authorization: 'Bearer test-token' },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects malformed JSON, server names, placeholders, and non-HTTP URLs', () => {
|
||||
expect(() => parseMcpDocument('{')).toThrow('expected JSON')
|
||||
expect(() => parseMcpDocument(JSON.stringify({
|
||||
mcpServers: { 'bad name': { command: 'server' } },
|
||||
}))).toThrow('server name')
|
||||
expect(() => parseMcpDocument(JSON.stringify({
|
||||
mcpServers: { bad: { command: '${BAD-NAME}' } },
|
||||
}))).toThrow('unsupported environment placeholder')
|
||||
expect(() => parseMcpDocument(JSON.stringify({
|
||||
mcpServers: { bad: { command: '${UNFINISHED' } },
|
||||
}))).toThrow('unterminated environment placeholder')
|
||||
const ftp = parseMcpDocument(JSON.stringify({
|
||||
mcpServers: { remote: { type: 'http', url: 'ftp://example.test/mcp' } },
|
||||
}))
|
||||
expect(() => resolveMcpServers(ftp, {}, '/plugin')).toThrow('must use http or https')
|
||||
})
|
||||
|
||||
it('rejects Work IQ OAuth fields instead of treating them as unauthenticated HTTP', () => {
|
||||
expect(() => parseMcpDocument(JSON.stringify({
|
||||
mcpServers: {
|
||||
workiq: {
|
||||
type: 'http',
|
||||
url: 'https://workiq.microsoft.com/mcp',
|
||||
oauthClientId: 'client-id',
|
||||
oauthPublicClient: true,
|
||||
auth: { redirectPort: 3317 },
|
||||
},
|
||||
},
|
||||
}))).toThrow('invalid .mcp.json')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, relative, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as RepositoryPlugin from '@deepseek-ai/dsh-repository-plugin'
|
||||
import * as RepositoryPluginInvariant from '@deepseek-ai/dsh-repository-plugin/invariant'
|
||||
import { parsePreparedPluginConfig } from '../src/format.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function temporaryDirectory(name: string): Promise<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), `dsh-repository-plugin-${name}-`))
|
||||
roots.push(directory)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function writePlugin(root: string, name: string, dsh: Record<string, unknown>): Promise<string> {
|
||||
const directory = join(root, '.dsh-plugin')
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(directory, 'package.json'), `${JSON.stringify({ name, version: '0.0.0', dsh }, undefined, 2)}\n`)
|
||||
return directory
|
||||
}
|
||||
|
||||
async function writeSkill(root: string, name: string): Promise<void> {
|
||||
const directory = join(root, name)
|
||||
await mkdir(directory, { recursive: true })
|
||||
await writeFile(join(directory, 'SKILL.md'), `---\nname: ${name}\ndescription: Repository fixture skill.\n---\n\nStatic instructions.\n`)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('dsh-plugin-prepare', () => {
|
||||
it('copies declared static assets and emits the fixed import-free wrapper', async () => {
|
||||
const root = await temporaryDirectory('prepare')
|
||||
await writeSkill(join(root, 'skills'), 'repository-fixture')
|
||||
await writeFile(join(root, '.mcp.json'), JSON.stringify({
|
||||
mcpServers: {
|
||||
expo: { type: 'http', url: 'https://mcp.expo.dev/mcp' },
|
||||
},
|
||||
}))
|
||||
const directory = await writePlugin(root, 'fixture-plugin', {
|
||||
skills: ['../skills'],
|
||||
mcpServers: '../.mcp.json',
|
||||
})
|
||||
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(directory)).resolves.toEqual({
|
||||
name: 'fixture-plugin',
|
||||
skills: ['dsh-plugin-assets/skills/0'],
|
||||
mcpServers: 'dsh-plugin-assets/.mcp.json',
|
||||
})
|
||||
const wrapper = await readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8')
|
||||
expect(wrapper).toContain(`ctx.loader.builtins["${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}"]`)
|
||||
expect(wrapper).not.toMatch(/\b(?:import|from)\s/)
|
||||
await expect(readFile(join(directory, 'dsh-plugin-assets/skills/0/repository-fixture/SKILL.md'), 'utf8'))
|
||||
.resolves.toContain('Static instructions.')
|
||||
await expect(readFile(join(directory, 'dsh-plugin-assets/.mcp.json'), 'utf8'))
|
||||
.resolves.toContain('mcp.expo.dev')
|
||||
})
|
||||
|
||||
it('rejects unsupported OAuth MCP metadata before publishing outputs', async () => {
|
||||
const root = await temporaryDirectory('oauth')
|
||||
await writeFile(join(root, '.mcp.json'), JSON.stringify({
|
||||
mcpServers: {
|
||||
workiq: {
|
||||
type: 'http',
|
||||
url: 'https://workiq.microsoft.com/mcp',
|
||||
oauthClientId: 'client-id',
|
||||
oauthPublicClient: true,
|
||||
auth: { redirectPort: 3317 },
|
||||
},
|
||||
},
|
||||
}))
|
||||
const directory = await writePlugin(root, 'unsupported-oauth', { mcpServers: '../.mcp.json' })
|
||||
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(directory)).rejects.toThrow('invalid .mcp.json')
|
||||
await expect(readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
})
|
||||
|
||||
it('rejects invalid metadata, missing assets, wrong asset types, and escaped paths', async () => {
|
||||
const malformedRoot = await temporaryDirectory('malformed-package')
|
||||
const malformed = join(malformedRoot, '.dsh-plugin')
|
||||
await mkdir(malformed)
|
||||
await writeFile(join(malformed, 'package.json'), '{')
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(malformed)).rejects.toThrow('failed to read DSH plugin package metadata')
|
||||
|
||||
const emptyRoot = await temporaryDirectory('empty-metadata')
|
||||
const empty = await writePlugin(emptyRoot, 'empty', {})
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(empty)).rejects.toThrow('declare at least one skill root or mcpServers file')
|
||||
|
||||
const missingRoot = await temporaryDirectory('missing-asset')
|
||||
const missing = await writePlugin(missingRoot, 'missing', { skills: ['../missing'] })
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(missing)).rejects.toThrow('asset does not exist')
|
||||
|
||||
const absoluteRoot = await temporaryDirectory('absolute-asset')
|
||||
const absolute = await writePlugin(absoluteRoot, 'absolute', { skills: [absoluteRoot] })
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(absolute)).rejects.toThrow('asset path must be relative')
|
||||
|
||||
const wrongTypeRoot = await temporaryDirectory('wrong-type')
|
||||
await writeFile(join(wrongTypeRoot, 'not-a-directory'), 'text')
|
||||
const wrongType = await writePlugin(wrongTypeRoot, 'wrong-type', { skills: ['../not-a-directory'] })
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(wrongType)).rejects.toThrow('asset is not a directory')
|
||||
|
||||
const wrongMcpRoot = await temporaryDirectory('wrong-mcp-type')
|
||||
await mkdir(join(wrongMcpRoot, 'not-a-file'))
|
||||
const wrongMcp = await writePlugin(wrongMcpRoot, 'wrong-mcp', { mcpServers: '../not-a-file' })
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(wrongMcp)).rejects.toThrow('asset is not a file')
|
||||
|
||||
const containingRoot = await temporaryDirectory('containing-root')
|
||||
const containing = await writePlugin(containingRoot, 'containing', { skills: ['..'] })
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(containing)).rejects.toThrow('cannot contain the .dsh-plugin package')
|
||||
|
||||
const escapedRoot = await temporaryDirectory('escaped-root')
|
||||
const outside = await temporaryDirectory('outside-root')
|
||||
await writeSkill(outside, 'outside-skill')
|
||||
const escaped = await writePlugin(escapedRoot, 'escaped', { skills: [relative(join(escapedRoot, '.dsh-plugin'), outside)] })
|
||||
await expect(RepositoryPlugin.prepareDshPlugin(escaped)).rejects.toThrow('escapes its plugin source root')
|
||||
})
|
||||
|
||||
it('validates prepared wrapper configs with and without MCP assets', () => {
|
||||
expect(() => parsePreparedPluginConfig({})).toThrow('invalid prepared DSH plugin')
|
||||
expect(parsePreparedPluginConfig({
|
||||
baseUrl: 'file:///plugin/dsh-plugin.mjs',
|
||||
manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json' },
|
||||
})).toEqual({
|
||||
baseUrl: 'file:///plugin/dsh-plugin.mjs',
|
||||
manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('prepared repository plugin Loader composition', () => {
|
||||
it('mounts and removes copied skills through the real Loader and skill-local provider', async () => {
|
||||
const root = await temporaryDirectory('loader')
|
||||
await writeSkill(join(root, 'skills'), 'loaded-from-repository')
|
||||
const directory = await writePlugin(root, 'loader-fixture', { skills: ['../skills'] })
|
||||
await RepositoryPlugin.prepareDshPlugin(directory)
|
||||
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(directory).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(SkillService)
|
||||
const registrar = ctx.plugin(RepositoryPlugin)
|
||||
await registrar
|
||||
expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeDefined()
|
||||
|
||||
const id = await ctx.loader.create({
|
||||
name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href,
|
||||
})
|
||||
await ctx.loader.await()
|
||||
await expect(ctx.skills.get('loaded-from-repository')).resolves.toMatchObject({
|
||||
name: 'loaded-from-repository',
|
||||
provider: 'repository:loader-fixture',
|
||||
content: 'Static instructions.',
|
||||
})
|
||||
|
||||
await ctx.loader.remove(id)
|
||||
await expect(ctx.skills.get('loaded-from-repository')).resolves.toBeUndefined()
|
||||
await registrar.dispose()
|
||||
expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBeUndefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('delegates an MCP-only plugin to the existing client without turning connect failure into Loader failure', async () => {
|
||||
const root = await temporaryDirectory('mcp-loader')
|
||||
await writeFile(join(root, '.mcp.json'), JSON.stringify({
|
||||
mcpServers: { offline: { command: join(root, 'missing-mcp-command') } },
|
||||
}))
|
||||
const directory = await writePlugin(root, 'mcp-loader-fixture', { mcpServers: '../.mcp.json' })
|
||||
await RepositoryPlugin.prepareDshPlugin(directory)
|
||||
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(directory).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(RepositoryPlugin)
|
||||
const id = await ctx.loader.create({
|
||||
name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href,
|
||||
})
|
||||
await ctx.loader.await()
|
||||
expect(ctx.tools.schemas().some(tool => tool.name.startsWith('mcp__offline__'))).toBe(false)
|
||||
await ctx.loader.remove(id)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects hostile prepared paths before mounting children', async () => {
|
||||
const root = await temporaryDirectory('prepared-paths')
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(root).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(RepositoryPlugin)
|
||||
|
||||
for (const [filename, skillPath] of [
|
||||
['absolute.mjs', resolve(root)],
|
||||
['escaped.mjs', '../outside'],
|
||||
] as const) {
|
||||
const wrapper = join(root, filename)
|
||||
await writeFile(wrapper, [
|
||||
"export const inject = ['loader']",
|
||||
'export async function apply(ctx) {',
|
||||
` await ctx.plugin(ctx.loader.builtins['${RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN}'], {`,
|
||||
` baseUrl: import.meta.url, manifest: { name: 'hostile', skills: [${JSON.stringify(skillPath)}] },`,
|
||||
' })',
|
||||
'}',
|
||||
'',
|
||||
].join('\n'))
|
||||
await expect(ctx.loader.create({ name: pathToFileURL(wrapper).href })).rejects.toThrow('prepared DSH plugin path')
|
||||
}
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects duplicate builtin ownership and preserves a later replacement on teardown', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Loader)
|
||||
const registrar = ctx.plugin(RepositoryPlugin)
|
||||
await registrar
|
||||
expect(() => { RepositoryPlugin.apply(ctx) }).toThrow('already registered')
|
||||
|
||||
const replacement = { name: 'replacement', apply() {} }
|
||||
ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN] = replacement
|
||||
await registrar.dispose()
|
||||
expect(ctx.loader.builtins[RepositoryPlugin.REPOSITORY_PLUGIN_BUILTIN]).toBe(replacement)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('repository plugin invariant companion', () => {
|
||||
it('registers its explained empty invariant', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(RepositoryPluginInvariant).await()).resolves.toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user