mirror of
https://gitee.com/pnoker/iot-dc3.git
synced 2026-11-05 07:45:10 +08:00
First Phase-3 slice: dc3 tools list/call speak JSON-RPC to POST /mcp with the same Bearer ticket as REST, reading the live tool catalog instead of hard-coded paths. Requires an oauth-type login; classic tickets get a clean exit-code-3 auth error. Five unit suites cover request shaping, scope rejection, rpc errors and the 401 challenge.
98 lines
3.5 KiB
TypeScript
98 lines
3.5 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
|
const { getState } = vi.hoisted(() => ({ getState: vi.fn() }));
|
|
|
|
vi.mock('../src/core/config-manager.js', () => ({
|
|
configManager: {
|
|
getActiveProfile: vi.fn(async () => ({ gateway: 'http://gw.example.com/' })),
|
|
load: vi.fn(async () => ({ current_profile: 'default' })),
|
|
},
|
|
}));
|
|
|
|
vi.mock('../src/core/token-manager.js', () => ({
|
|
tokenManager: {
|
|
getState,
|
|
buildHeaders: (state: Record<string, unknown>) => ({
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${state.token}`,
|
|
}),
|
|
},
|
|
}));
|
|
|
|
import { AuthError } from '../src/core/client.js';
|
|
import { McpClient } from '../src/core/mcp.js';
|
|
|
|
const oauthState = { token: 'rs256.jwt.value', authType: 'oauth' };
|
|
const loginState = { token: 'hs.jwt.value', authType: 'login' };
|
|
|
|
describe('McpClient', () => {
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it('sends a JSON-RPC tools/list with the Bearer ticket to /mcp', async () => {
|
|
getState.mockResolvedValue(oauthState);
|
|
const fetchMock = vi.fn().mockResolvedValue(new Response(
|
|
JSON.stringify({ jsonrpc: '2.0', id: 1, result: { tools: [{ name: 'read_device' }] } }),
|
|
{ status: 200 },
|
|
));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const client = new McpClient();
|
|
const result = await client.listTools();
|
|
|
|
expect(result.tools?.[0].name).toBe('read_device');
|
|
const [url, init] = fetchMock.mock.calls[0];
|
|
expect(url).toBe('http://gw.example.com/mcp');
|
|
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer rs256.jwt.value');
|
|
const body = JSON.parse(init.body);
|
|
expect(body.jsonrpc).toBe('2.0');
|
|
expect(body.method).toBe('tools/list');
|
|
});
|
|
|
|
it('rejects classic login tickets with an auth error before calling /mcp', async () => {
|
|
getState.mockResolvedValue(loginState);
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const client = new McpClient();
|
|
await expect(client.callTool('x', {})).rejects.toBeInstanceOf(AuthError);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('surfaces JSON-RPC error objects as failures', async () => {
|
|
getState.mockResolvedValue(oauthState);
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(
|
|
JSON.stringify({ jsonrpc: '2.0', id: 1, error: { code: -32000, message: 'denied' } }),
|
|
{ status: 200 },
|
|
)));
|
|
|
|
const client = new McpClient();
|
|
await expect(client.listTools()).rejects.toThrow(/MCP error -32000: denied/);
|
|
});
|
|
|
|
it('maps a 401 challenge to a clean auth error', async () => {
|
|
getState.mockResolvedValue(oauthState);
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('unauthorized', { status: 401 })));
|
|
|
|
const client = new McpClient();
|
|
await expect(client.listTools()).rejects.toBeInstanceOf(AuthError);
|
|
});
|
|
|
|
it('passes tool name and arguments through on tools/call', async () => {
|
|
getState.mockResolvedValue(oauthState);
|
|
const fetchMock = vi.fn().mockResolvedValue(new Response(
|
|
JSON.stringify({ jsonrpc: '2.0', id: 7, result: { content: [{ text: 'ok' }] } }),
|
|
{ status: 200 },
|
|
));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const client = new McpClient();
|
|
const out = await client.callTool('read_device', { deviceId: 1 });
|
|
expect(out).toEqual({ content: [{ text: 'ok' }] });
|
|
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
|
|
expect(body.method).toBe('tools/call');
|
|
expect(body.params).toEqual({ name: 'read_device', arguments: { deviceId: 1 } });
|
|
});
|
|
});
|