Files
mcpctl/src/mcplocal/tests/link-resolver.test.ts
Michal ecc9c48597 feat: gated project experience & prompt intelligence
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>
2026-02-25 23:22:42 +00:00

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');
});
});
});