Files
mcpctl/src/mcplocal/tests/project-mcp-endpoint.test.ts
Michal b025ade2b0 feat: add prompt resources, fix MCP proxy transport, enrich tool descriptions
- Fix MCP proxy to support SSE and STDIO transports (not just HTTP POST)
- Enrich tool descriptions with server context for LLM clarity
- Add Prompt and PromptRequest resources with two-resource RBAC model
- Add propose_prompt MCP tool for LLM to create pending prompt requests
- Add prompt resources visible in MCP resources/list (approved + session's pending)
- Add project-level prompt/instructions in MCP initialize response
- Add ServiceAccount subject type for RBAC (SA identity from X-Service-Account header)
- Add CLI commands: create prompt, get prompts/promptrequests, approve promptrequest
- Add prompts to apply config schema
- 956 tests passing across all packages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:53:00 +00:00

177 lines
5.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { registerProjectMcpEndpoint } from '../src/http/project-mcp-endpoint.js';
// Mock discovery module — we don't want real HTTP calls
vi.mock('../src/discovery.js', () => ({
refreshProjectUpstreams: vi.fn(async () => ['mock-server']),
}));
import { refreshProjectUpstreams } from '../src/discovery.js';
function mockMcpdClient() {
const client: Record<string, unknown> = {
baseUrl: 'http://test:3100',
token: 'test-token',
get: vi.fn(async () => []),
post: vi.fn(async () => ({})),
put: vi.fn(),
delete: vi.fn(),
forward: vi.fn(async () => ({ status: 200, body: [] })),
withHeaders: vi.fn(),
};
// withHeaders returns a new client-like object (returns self for simplicity)
(client.withHeaders as ReturnType<typeof vi.fn>).mockReturnValue(client);
return client;
}
describe('registerProjectMcpEndpoint', () => {
let app: FastifyInstance;
beforeEach(async () => {
vi.clearAllMocks();
app = Fastify();
registerProjectMcpEndpoint(app, mockMcpdClient() as any);
await app.ready();
});
it('registers POST /projects/:projectName/mcp route', async () => {
// The endpoint should exist and attempt to handle MCP protocol
const res = await app.inject({
method: 'POST',
url: '/projects/smart-home/mcp',
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
headers: { 'content-type': 'application/json' },
});
// The StreamableHTTPServerTransport hijacks the response,
// so we may get a 200 or the transport handles it directly
expect(res.statusCode).not.toBe(404);
});
it('calls refreshProjectUpstreams with project name', async () => {
await app.inject({
method: 'POST',
url: '/projects/smart-home/mcp',
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
headers: { 'content-type': 'application/json' },
});
expect(refreshProjectUpstreams).toHaveBeenCalledWith(
expect.any(Object), // McpRouter instance
expect.any(Object), // McpdClient
'smart-home',
undefined, // no auth token
);
});
it('forwards auth token from Authorization header', async () => {
await app.inject({
method: 'POST',
url: '/projects/secure-project/mcp',
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
headers: {
'content-type': 'application/json',
'authorization': 'Bearer my-token-123',
},
});
expect(refreshProjectUpstreams).toHaveBeenCalledWith(
expect.any(Object),
expect.any(Object),
'secure-project',
'my-token-123',
);
});
it('returns 502 when project discovery fails', async () => {
vi.mocked(refreshProjectUpstreams).mockRejectedValueOnce(new Error('Forbidden'));
const res = await app.inject({
method: 'POST',
url: '/projects/bad-project/mcp',
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
headers: { 'content-type': 'application/json' },
});
expect(res.statusCode).toBe(502);
expect(res.json().error).toContain('Failed to load project');
});
it('returns 404 for unknown session ID', async () => {
const res = await app.inject({
method: 'POST',
url: '/projects/smart-home/mcp',
payload: { jsonrpc: '2.0', id: 1, method: 'tools/list', params: {} },
headers: {
'content-type': 'application/json',
'mcp-session-id': 'nonexistent-session',
},
});
expect(res.statusCode).toBe(404);
});
it('returns 400 for GET without session', async () => {
const res = await app.inject({
method: 'GET',
url: '/projects/smart-home/mcp',
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('session');
});
it('returns 400 for DELETE without session', async () => {
const res = await app.inject({
method: 'DELETE',
url: '/projects/smart-home/mcp',
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('session');
});
it('caches project router across requests', async () => {
// Two requests to the same project should reuse the router
await app.inject({
method: 'POST',
url: '/projects/smart-home/mcp',
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
headers: { 'content-type': 'application/json' },
});
await app.inject({
method: 'POST',
url: '/projects/smart-home/mcp',
payload: { jsonrpc: '2.0', id: 2, method: 'initialize', params: {} },
headers: { 'content-type': 'application/json' },
});
// refreshProjectUpstreams should only be called once (cached)
expect(refreshProjectUpstreams).toHaveBeenCalledTimes(1);
});
it('creates separate routers for different projects', async () => {
await app.inject({
method: 'POST',
url: '/projects/project-a/mcp',
payload: { jsonrpc: '2.0', id: 1, method: 'initialize', params: {} },
headers: { 'content-type': 'application/json' },
});
await app.inject({
method: 'POST',
url: '/projects/project-b/mcp',
payload: { jsonrpc: '2.0', id: 2, method: 'initialize', params: {} },
headers: { 'content-type': 'application/json' },
});
// Two different projects should trigger two refreshes
expect(refreshProjectUpstreams).toHaveBeenCalledTimes(2);
expect(refreshProjectUpstreams).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), 'project-a', undefined);
expect(refreshProjectUpstreams).toHaveBeenCalledWith(expect.any(Object), expect.any(Object), 'project-b', undefined);
});
});