Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions packages/mcp-server/src/doctor/checks/native-host-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('node:fs/promises', () => ({
access: vi.fn(),
readFile: vi.fn(),
}));

vi.mock('../../store/path.js', () => ({
getNativeHostManifestPath: vi.fn(() => '/tmp/com.onui.native.json'),
}));

import { access, readFile } from 'node:fs/promises';
import { checkNativeHostManifest } from './native-host-manifest.js';

const accessMock = vi.mocked(access);
const readFileMock = vi.mocked(readFile);

describe('checkNativeHostManifest', () => {
beforeEach(() => {
vi.resetAllMocks();
});

it('returns ok when manifest includes expected Chrome origins', async () => {
accessMock.mockResolvedValue(undefined);
readFileMock.mockResolvedValue(
JSON.stringify({
name: 'com.onui.native',
path: '/tmp/onui-native-host.sh',
allowed_origins: [
'chrome-extension://hllgijkdhegkpooopdhbfdjialkhlkan/',
'chrome-extension://fnkengnadapimmlepnjienecfoekgacp/',
],
})
);

const result = await checkNativeHostManifest();
expect(result.status).toBe('ok');
expect(result.name).toBe('native.manifest');
});

it('returns warning when expected origins are missing', async () => {
accessMock.mockResolvedValue(undefined);
readFileMock.mockResolvedValue(
JSON.stringify({
name: 'com.onui.native',
path: '/tmp/onui-native-host.sh',
allowed_origins: ['chrome-extension://fnkengnadapimmlepnjienecfoekgacp/'],
})
);

const result = await checkNativeHostManifest();
expect(result.status).toBe('warning');
expect(result.message).toContain('missing expected Chrome origins');
expect(result.fix).toContain('pnpm --filter @onui/mcp-server setup');
});

it('returns warning when allowed_origins is absent', async () => {
accessMock.mockResolvedValue(undefined);
readFileMock.mockResolvedValue(
JSON.stringify({
name: 'com.onui.native',
path: '/tmp/onui-native-host.sh',
})
);

const result = await checkNativeHostManifest();
expect(result.status).toBe('warning');
});

it('returns error when required manifest fields are invalid', async () => {
accessMock.mockResolvedValue(undefined);
readFileMock.mockResolvedValue(JSON.stringify({ name: 'com.onui.native' }));

const result = await checkNativeHostManifest();
expect(result.status).toBe('error');
expect(result.message).toContain('Manifest is invalid');
});

it('returns warning when manifest file does not exist', async () => {
accessMock.mockRejectedValue(new Error('not found'));

const result = await checkNativeHostManifest();
expect(result.status).toBe('warning');
expect(result.message).toContain('Native manifest not found');
});
});
25 changes: 24 additions & 1 deletion packages/mcp-server/src/doctor/checks/native-host-manifest.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { access, readFile } from 'node:fs/promises';
import { getNativeHostManifestPath } from '../../store/path.js';
import { buildChromeAllowedOrigins } from '../../setup/install-native-host.js';
import type { CheckResult } from '../types.js';

export async function checkNativeHostManifest(): Promise<CheckResult> {
Expand All @@ -8,7 +9,7 @@ export async function checkNativeHostManifest(): Promise<CheckResult> {
try {
await access(manifestPath);
const raw = await readFile(manifestPath, 'utf8');
const parsed = JSON.parse(raw) as { path?: string; name?: string };
const parsed = JSON.parse(raw) as { path?: string; name?: string; allowed_origins?: unknown };

if (!parsed.path || !parsed.name) {
return {
Expand All @@ -19,13 +20,35 @@ export async function checkNativeHostManifest(): Promise<CheckResult> {
};
}

const expectedOrigins = new Set(buildChromeAllowedOrigins());
const allowedOrigins = Array.isArray(parsed.allowed_origins)
? parsed.allowed_origins.filter((origin): origin is string => typeof origin === 'string')
: [];
const missingExpectedOrigins = Array.from(expectedOrigins).filter(
(origin) => !allowedOrigins.includes(origin)
);

if (missingExpectedOrigins.length > 0) {
return {
name: 'native.manifest',
status: 'warning',
message: `Native manifest is missing expected Chrome origins at ${manifestPath}`,
fix: 'Rerun setup to refresh allowed_origins: pnpm --filter @onui/mcp-server setup',
details: {
missingOrigins: missingExpectedOrigins,
allowedOrigins,
},
};
}

return {
name: 'native.manifest',
status: 'ok',
message: `Native manifest exists at ${manifestPath}`,
details: {
hostName: parsed.name,
path: parsed.path,
allowedOrigins,
},
};
} catch {
Expand Down
29 changes: 29 additions & 0 deletions packages/mcp-server/src/setup/install-native-host.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { buildChromeAllowedOrigins } from './install-native-host.js';

describe('buildChromeAllowedOrigins', () => {
it('includes both known Chrome extension origins by default', () => {
const origins = buildChromeAllowedOrigins();
expect(origins).toEqual([
'chrome-extension://hllgijkdhegkpooopdhbfdjialkhlkan/',
'chrome-extension://fnkengnadapimmlepnjienecfoekgacp/',
]);
});

it('deduplicates and normalizes valid extension ids', () => {
const origins = buildChromeAllowedOrigins([
'HLLGIJKDHEGKPOOOPDHBFDJIALKHLKAN',
'hllgijkdhegkpooopdhbfdjialkhlkan',
'fnkengnadapimmlepnjienecfoekgacp',
]);
expect(origins).toEqual([
'chrome-extension://hllgijkdhegkpooopdhbfdjialkhlkan/',
'chrome-extension://fnkengnadapimmlepnjienecfoekgacp/',
]);
});

it('ignores invalid extension ids', () => {
const origins = buildChromeAllowedOrigins(['invalid-id', '', 'abc', 'hllgijkdhegkpooopdhbfdjialkhlkan']);
expect(origins).toEqual(['chrome-extension://hllgijkdhegkpooopdhbfdjialkhlkan/']);
});
});
29 changes: 27 additions & 2 deletions packages/mcp-server/src/setup/install-native-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,32 @@ import {
getNativeHostWindowsRegistryPath,
} from '../store/path.js';

const EXTENSION_ID = 'fnkengnadapimmlepnjienecfoekgacp';
const CHROME_WEB_STORE_EXTENSION_ID = 'hllgijkdhegkpooopdhbfdjialkhlkan';
const CHROME_UNPACKED_EXTENSION_ID = 'fnkengnadapimmlepnjienecfoekgacp';

function normalizeExtensionId(id: string): string | undefined {
const normalized = id.trim().toLowerCase();
if (!/^[a-p]{32}$/.test(normalized)) {
return undefined;
}
return normalized;
}

export function buildChromeAllowedOrigins(extensionIds: readonly string[] = [
CHROME_WEB_STORE_EXTENSION_ID,
CHROME_UNPACKED_EXTENSION_ID,
]): string[] {
const deduped = new Set<string>();

for (const id of extensionIds) {
const normalized = normalizeExtensionId(id);
if (normalized) {
deduped.add(normalized);
}
}

return Array.from(deduped).map((id) => `chrome-extension://${id}/`);
}

export interface NativeHostInstallResult {
wrapperPath: string;
Expand Down Expand Up @@ -52,7 +77,7 @@ export async function installNativeHost(
description: 'onUI native messaging host',
path: wrapperPath,
type: 'stdio',
allowed_origins: [`chrome-extension://${EXTENSION_ID}/`],
allowed_origins: buildChromeAllowedOrigins(),
};

await writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8');
Expand Down