Implements the full gated session flow and prompt intelligence system: - Prisma schema: add gated, priority, summary, chapters, linkTarget fields - Session gate: state machine (gated → begin_session → ungated) with LLM-powered tool selection based on prompt index - Tag matcher: intelligent prompt-to-tool matching with project/server/action tags - LLM selector: tiered provider selection (fast for gating, heavy for complex tasks) - Link resolver: cross-project MCP resource references (project/server:uri format) - Prompt summary service: LLM-generated summaries and chapter extraction - System project bootstrap: ensures default project exists on startup - Structural link health checks: enrichWithLinkStatus on prompt GET endpoints - CLI: create prompt --priority/--link, create project --gated/--no-gated, describe project shows prompts section, get prompts shows PRI/LINK/STATUS - Apply/edit: priority, linkTarget, gated fields supported - Shell completions: fish updated with new flags - 1,253 tests passing across all packages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
242 lines
7.7 KiB
TypeScript
242 lines
7.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import { LinkResolver } from '../src/services/link-resolver.js';
|
|
import type { McpdClient } from '../src/http/mcpd-client.js';
|
|
|
|
function mockClient(): McpdClient {
|
|
return {
|
|
get: vi.fn(),
|
|
post: vi.fn(),
|
|
put: vi.fn(),
|
|
delete: vi.fn(),
|
|
forward: vi.fn(),
|
|
withHeaders: vi.fn(),
|
|
} as unknown as McpdClient;
|
|
}
|
|
|
|
describe('LinkResolver', () => {
|
|
let client: McpdClient;
|
|
let resolver: LinkResolver;
|
|
|
|
beforeEach(() => {
|
|
client = mockClient();
|
|
resolver = new LinkResolver(client, 1000); // 1s TTL for tests
|
|
});
|
|
|
|
// ── parseLink ──
|
|
|
|
describe('parseLink', () => {
|
|
it('parses valid link target', () => {
|
|
const result = resolver.parseLink('my-project/docmost-mcp:docmost://pages/abc');
|
|
expect(result).toEqual({
|
|
project: 'my-project',
|
|
server: 'docmost-mcp',
|
|
uri: 'docmost://pages/abc',
|
|
});
|
|
});
|
|
|
|
it('parses link with complex URI', () => {
|
|
const result = resolver.parseLink('proj/srv:file:///path/to/resource');
|
|
expect(result).toEqual({
|
|
project: 'proj',
|
|
server: 'srv',
|
|
uri: 'file:///path/to/resource',
|
|
});
|
|
});
|
|
|
|
it('throws on missing project separator', () => {
|
|
expect(() => resolver.parseLink('noslash')).toThrow('missing project');
|
|
});
|
|
|
|
it('throws on missing server:uri separator', () => {
|
|
expect(() => resolver.parseLink('proj/nocolon')).toThrow('missing server:uri');
|
|
});
|
|
|
|
it('throws on empty uri', () => {
|
|
expect(() => resolver.parseLink('proj/srv:')).toThrow('empty uri');
|
|
});
|
|
|
|
it('throws when project is empty', () => {
|
|
expect(() => resolver.parseLink('/srv:uri')).toThrow('missing project');
|
|
});
|
|
|
|
it('throws when server is empty', () => {
|
|
expect(() => resolver.parseLink('proj/:uri')).toThrow('missing server:uri');
|
|
});
|
|
});
|
|
|
|
// ── resolve ──
|
|
|
|
describe('resolve', () => {
|
|
it('fetches resource content successfully', async () => {
|
|
vi.mocked(client.get).mockResolvedValue([
|
|
{ id: 'srv-id-1', name: 'docmost-mcp' },
|
|
]);
|
|
vi.mocked(client.post).mockResolvedValue({
|
|
result: { contents: [{ text: 'Hello from docmost', uri: 'docmost://pages/abc' }] },
|
|
});
|
|
|
|
const result = await resolver.resolve('my-project/docmost-mcp:docmost://pages/abc');
|
|
|
|
expect(result).toEqual({ content: 'Hello from docmost', status: 'alive' });
|
|
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/my-project/servers');
|
|
expect(client.post).toHaveBeenCalledWith('/api/v1/mcp/proxy', {
|
|
serverId: 'srv-id-1',
|
|
method: 'resources/read',
|
|
params: { uri: 'docmost://pages/abc' },
|
|
});
|
|
});
|
|
|
|
it('returns dead status when server not found in project', async () => {
|
|
vi.mocked(client.get).mockResolvedValue([
|
|
{ id: 'srv-other', name: 'other-server' },
|
|
]);
|
|
|
|
const result = await resolver.resolve('proj/missing-srv:some://uri');
|
|
|
|
expect(result.status).toBe('dead');
|
|
expect(result.content).toBeNull();
|
|
expect(result.error).toContain("Server 'missing-srv' not found");
|
|
});
|
|
|
|
it('returns dead status when MCP proxy returns error', async () => {
|
|
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
|
vi.mocked(client.post).mockResolvedValue({
|
|
error: { code: -32601, message: 'Method not found' },
|
|
});
|
|
|
|
const result = await resolver.resolve('proj/srv:some://uri');
|
|
|
|
expect(result.status).toBe('dead');
|
|
expect(result.error).toContain('Method not found');
|
|
});
|
|
|
|
it('returns dead status when no content returned', async () => {
|
|
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
|
vi.mocked(client.post).mockResolvedValue({
|
|
result: { contents: [] },
|
|
});
|
|
|
|
const result = await resolver.resolve('proj/srv:some://uri');
|
|
|
|
expect(result.status).toBe('dead');
|
|
expect(result.error).toContain('No content returned');
|
|
});
|
|
|
|
it('returns dead status on network error', async () => {
|
|
vi.mocked(client.get).mockRejectedValue(new Error('Connection refused'));
|
|
|
|
const result = await resolver.resolve('proj/srv:some://uri');
|
|
|
|
expect(result.status).toBe('dead');
|
|
expect(result.error).toContain('Connection refused');
|
|
});
|
|
|
|
it('concatenates multiple content entries', async () => {
|
|
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
|
vi.mocked(client.post).mockResolvedValue({
|
|
result: {
|
|
contents: [
|
|
{ text: 'Part 1', uri: 'uri1' },
|
|
{ text: 'Part 2', uri: 'uri2' },
|
|
],
|
|
},
|
|
});
|
|
|
|
const result = await resolver.resolve('proj/srv:some://uri');
|
|
|
|
expect(result.content).toBe('Part 1\nPart 2');
|
|
expect(result.status).toBe('alive');
|
|
});
|
|
|
|
it('logs dead link to console.error', async () => {
|
|
vi.mocked(client.get).mockRejectedValue(new Error('fail'));
|
|
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
await resolver.resolve('proj/srv:some://uri');
|
|
|
|
expect(spy).toHaveBeenCalledWith(expect.stringContaining('[link-resolver] Dead link'));
|
|
spy.mockRestore();
|
|
});
|
|
});
|
|
|
|
// ── caching ──
|
|
|
|
describe('caching', () => {
|
|
it('returns cached result on second call', async () => {
|
|
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
|
vi.mocked(client.post).mockResolvedValue({
|
|
result: { contents: [{ text: 'cached content' }] },
|
|
});
|
|
|
|
const first = await resolver.resolve('proj/srv:some://uri');
|
|
const second = await resolver.resolve('proj/srv:some://uri');
|
|
|
|
expect(first).toEqual(second);
|
|
// Only one HTTP call — second was cached
|
|
expect(client.get).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('refetches after cache expires', async () => {
|
|
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
|
vi.mocked(client.post).mockResolvedValue({
|
|
result: { contents: [{ text: 'content' }] },
|
|
});
|
|
|
|
await resolver.resolve('proj/srv:some://uri');
|
|
|
|
// Advance time past TTL
|
|
vi.useFakeTimers();
|
|
vi.advanceTimersByTime(1500);
|
|
|
|
await resolver.resolve('proj/srv:some://uri');
|
|
|
|
expect(client.get).toHaveBeenCalledTimes(2);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it('clearCache removes all entries', async () => {
|
|
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
|
vi.mocked(client.post).mockResolvedValue({
|
|
result: { contents: [{ text: 'content' }] },
|
|
});
|
|
|
|
await resolver.resolve('proj/srv:some://uri');
|
|
resolver.clearCache();
|
|
await resolver.resolve('proj/srv:some://uri');
|
|
|
|
expect(client.get).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
|
|
// ── checkHealth ──
|
|
|
|
describe('checkHealth', () => {
|
|
it('returns cached status if available', async () => {
|
|
vi.mocked(client.get).mockResolvedValue([{ id: 'srv-1', name: 'srv' }]);
|
|
vi.mocked(client.post).mockResolvedValue({
|
|
result: { contents: [{ text: 'content' }] },
|
|
});
|
|
|
|
await resolver.resolve('proj/srv:some://uri');
|
|
const health = await resolver.checkHealth('proj/srv:some://uri');
|
|
|
|
expect(health).toBe('alive');
|
|
});
|
|
|
|
it('returns unknown if not cached', async () => {
|
|
const health = await resolver.checkHealth('proj/srv:some://uri');
|
|
expect(health).toBe('unknown');
|
|
});
|
|
|
|
it('returns dead from cached dead link', async () => {
|
|
vi.mocked(client.get).mockRejectedValue(new Error('fail'));
|
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
|
|
await resolver.resolve('proj/srv:some://uri');
|
|
const health = await resolver.checkHealth('proj/srv:some://uri');
|
|
|
|
expect(health).toBe('dead');
|
|
});
|
|
});
|
|
});
|