Compare commits
3 Commits
clawsweepe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe87142d89 | ||
|
|
782e028abe | ||
|
|
2a9b353b21 |
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
os: [ubuntu-latest, macos-15, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
@ -48,6 +48,11 @@ jobs:
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm --version
|
||||
- run: pnpm check
|
||||
if: matrix.os != 'macos-15'
|
||||
|
||||
- name: Check without type-aware oxlint
|
||||
if: matrix.os == 'macos-15'
|
||||
run: pnpm format:check && pnpm typecheck
|
||||
|
||||
- name: Verify generated schema is committed
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
|
||||
2
.github/workflows/crabbox-hydrate.yml
vendored
2
.github/workflows/crabbox-hydrate.yml
vendored
@ -39,7 +39,7 @@ jobs:
|
||||
runs-on: [self-hosted, crabbox, openclaw, mcporter, '${{ inputs.crabbox_runner_label }}']
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
|
||||
2
.github/workflows/pages.yml
vendored
2
.github/workflows/pages.yml
vendored
@ -30,7 +30,7 @@ jobs:
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- name: Check out
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Node
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
24
package.json
24
package.json
@ -72,31 +72,31 @@
|
||||
"dependencies": {
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"acorn": "^8.16.0",
|
||||
"commander": "^14.0.3",
|
||||
"es-toolkit": "^1.47.0",
|
||||
"acorn": "^8.17.0",
|
||||
"commander": "^15.0.0",
|
||||
"es-toolkit": "^1.48.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"ora": "^9.4.0",
|
||||
"rolldown": "1.0.1",
|
||||
"ora": "^9.4.1",
|
||||
"rolldown": "1.1.2",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/estree": "^1.0.9",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.9.2",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260514.1",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@types/node": "^26.0.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260623.1",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"bun-types": "^1.3.14",
|
||||
"cross-env": "^10.1.0",
|
||||
"express": "^5.2.1",
|
||||
"oxfmt": "^0.49.0",
|
||||
"oxlint": "^1.69.0",
|
||||
"oxlint-tsgolint": "^0.22.1",
|
||||
"oxfmt": "^0.56.0",
|
||||
"oxlint": "^1.71.0",
|
||||
"oxlint-tsgolint": "^0.23.0",
|
||||
"rimraf": "^6.1.3",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "8.0.16",
|
||||
"vitest": "^4.1.8"
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"devEngines": {
|
||||
"runtime": [
|
||||
|
||||
954
pnpm-lock.yaml
generated
954
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
57
src/cli.ts
57
src/cli.ts
@ -14,8 +14,6 @@ export { extractListFlags } from './cli/list-flags.js';
|
||||
export { resolveCallTimeout } from './cli/timeouts.js';
|
||||
|
||||
const FORCE_EXIT_GRACE_MS = 50;
|
||||
const FORCE_EXIT_STDIO_FLUSH_TIMEOUT_MS = 500;
|
||||
const FORCE_EXIT_STDIO_SETTLE_MS = 0;
|
||||
const DAEMON_FAST_PATH_SERVERS = new Set(['chrome-devtools', 'mobile-mcp', 'playwright']);
|
||||
|
||||
export async function handleAuth(
|
||||
@ -358,9 +356,6 @@ async function closeRuntimeAfterCommand(
|
||||
}, FORCE_EXIT_GRACE_MS);
|
||||
}
|
||||
};
|
||||
if (shouldForceExit) {
|
||||
await flushProcessStdio();
|
||||
}
|
||||
if (DEBUG_HANG) {
|
||||
dumpActiveHandles('after terminateChildProcesses');
|
||||
scheduleForcedExit();
|
||||
@ -373,58 +368,6 @@ async function closeRuntimeAfterCommand(
|
||||
}
|
||||
}
|
||||
|
||||
async function flushProcessStdio(timeoutMs = FORCE_EXIT_STDIO_FLUSH_TIMEOUT_MS): Promise<void> {
|
||||
await Promise.allSettled([
|
||||
flushWriteStream(process.stdout, timeoutMs, FORCE_EXIT_STDIO_SETTLE_MS),
|
||||
flushWriteStream(process.stderr, timeoutMs, FORCE_EXIT_STDIO_SETTLE_MS),
|
||||
]);
|
||||
}
|
||||
|
||||
function flushWriteStream(stream: NodeJS.WriteStream, timeoutMs: number, settleMs: number): Promise<void> {
|
||||
if (!stream.writable || stream.destroyed || stream.writableEnded) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
if (stream.writableLength === 0 && !stream.writableNeedDrain) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const cleanup = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
stream.off('drain', finishAfterDrain);
|
||||
stream.off('error', cleanup);
|
||||
resolve();
|
||||
};
|
||||
const finishAfterDrain = () => {
|
||||
setImmediate(cleanup);
|
||||
};
|
||||
const finishWhenDrained = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
if (stream.destroyed || stream.writableEnded || (!stream.writableNeedDrain && stream.writableLength === 0)) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
setImmediate(finishWhenDrained);
|
||||
};
|
||||
|
||||
timeout = setTimeout(cleanup, stream.writableNeedDrain ? timeoutMs : settleMs);
|
||||
stream.once('drain', finishAfterDrain);
|
||||
stream.once('error', cleanup);
|
||||
setImmediate(finishWhenDrained);
|
||||
});
|
||||
}
|
||||
|
||||
function wrapperArgsBeforeSeparator(args: readonly string[]): string[] {
|
||||
const separatorIndex = args.indexOf('--');
|
||||
return separatorIndex === -1 ? [...args] : args.slice(0, separatorIndex);
|
||||
|
||||
@ -452,18 +452,24 @@ async function prepareSocket(socketPath: string): Promise<void> {
|
||||
}
|
||||
|
||||
async function cleanupArtifacts(options: DaemonHostOptions): Promise<void> {
|
||||
await cleanupDaemonArtifactsIfOwned(options, process.pid);
|
||||
}
|
||||
|
||||
export async function cleanupDaemonArtifactsIfOwned(
|
||||
paths: Pick<DaemonHostOptions, 'metadataPath' | 'socketPath'>,
|
||||
ownerPid: number
|
||||
): Promise<void> {
|
||||
// A superseded daemon may finish shutting down after its replacement has
|
||||
// already rebound the same paths. Never let that old process unlink the
|
||||
// replacement daemon's live socket and metadata.
|
||||
const metadata = await readJsonFile<{ pid?: number; socketPath?: string }>(paths.metadataPath).catch(() => undefined);
|
||||
if (metadata?.pid !== ownerPid || metadata.socketPath !== paths.socketPath) {
|
||||
return;
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
await fs.unlink(options.socketPath);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fs.unlink(options.metadataPath);
|
||||
} catch {
|
||||
// ignore
|
||||
await fs.unlink(paths.socketPath).catch(() => {});
|
||||
}
|
||||
await fs.unlink(paths.metadataPath).catch(() => {});
|
||||
}
|
||||
|
||||
async function handleSocketRequest(
|
||||
|
||||
@ -12,9 +12,6 @@ const testRequire = createRequire(import.meta.url);
|
||||
const MCP_SERVER_MODULE = pathToFileURL(testRequire.resolve('@modelcontextprotocol/sdk/server/mcp.js')).href;
|
||||
const STDIO_SERVER_MODULE = pathToFileURL(testRequire.resolve('@modelcontextprotocol/sdk/server/stdio.js')).href;
|
||||
const ZOD_MODULE = pathToFileURL(path.join(process.cwd(), 'node_modules', 'zod', 'index.js')).href;
|
||||
// This harness patches Node's stdout internals to synthesize an emitted EPIPE.
|
||||
// Keep the cross-platform coverage in the large-output test below.
|
||||
const itWithMutableStdoutInternals = process.platform === 'win32' ? it.skip : it;
|
||||
|
||||
async function ensureDistBuilt(): Promise<void> {
|
||||
try {
|
||||
@ -42,83 +39,6 @@ function runCli(args: string[], configPath: string): Promise<{ stdout: string; s
|
||||
});
|
||||
}
|
||||
|
||||
async function runCliWithCleanupStdoutError(
|
||||
configPath: string,
|
||||
tempDir: string
|
||||
): Promise<{ stdout: string; stderr: string; code: number }> {
|
||||
const script = `
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const [cliEntry, configPath] = process.argv.slice(2);
|
||||
process.env.MCPORTER_DISABLE_AUTORUN = '1';
|
||||
|
||||
let cleanupErrorListenerSeen = false;
|
||||
let cleanupEmptyWriteSeen = false;
|
||||
let cliRunning = false;
|
||||
Object.defineProperty(process.stdout, 'writableNeedDrain', {
|
||||
configurable: true,
|
||||
get: () => false,
|
||||
});
|
||||
Object.defineProperty(process.stdout, 'writableLength', {
|
||||
configurable: true,
|
||||
get: () => 1,
|
||||
});
|
||||
const originalOnce = process.stdout.once.bind(process.stdout);
|
||||
process.stdout.once = (event, listener) => {
|
||||
const result = originalOnce(event, listener);
|
||||
if (cliRunning && event === 'error' && !cleanupErrorListenerSeen) {
|
||||
cleanupErrorListenerSeen = true;
|
||||
const error = new Error('write EPIPE');
|
||||
error.code = 'EPIPE';
|
||||
process.stdout.emit('error', error);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const originalWrite = process.stdout.write.bind(process.stdout);
|
||||
process.stdout.write = (chunk, encoding, callback) => {
|
||||
if (cliRunning && chunk === '') {
|
||||
cleanupEmptyWriteSeen = true;
|
||||
}
|
||||
return originalWrite(chunk, encoding, callback);
|
||||
};
|
||||
|
||||
const { runCli } = await import(pathToFileURL(cliEntry).href);
|
||||
cliRunning = true;
|
||||
await runCli(['--config', configPath, 'list', 'force-exit', '--schema', '--output', 'json']);
|
||||
if (!cleanupErrorListenerSeen) {
|
||||
console.error('expected force-exit cleanup to observe stdout errors');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
if (cleanupEmptyWriteSeen) {
|
||||
console.error('expected force-exit cleanup not to write to stdout');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
`;
|
||||
const scriptPath = path.join(tempDir, 'cleanup-stdout-error.mjs');
|
||||
await fs.writeFile(scriptPath, script, 'utf8');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[scriptPath, CLI_ENTRY, configPath],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: process.env,
|
||||
maxBuffer: 1024 * 1024,
|
||||
timeout: 15000,
|
||||
},
|
||||
(error, stdout, stderr) => {
|
||||
const code = typeof error?.code === 'number' ? error.code : 0;
|
||||
if (error && typeof error.code !== 'number') {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve({ stdout, stderr, code });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe('mcporter forced exit behavior', () => {
|
||||
let tempDir: string;
|
||||
let configPath: string;
|
||||
@ -128,7 +48,7 @@ describe('mcporter forced exit behavior', () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-force-exit-'));
|
||||
const serverScriptPath = path.join(tempDir, 'force-exit-server.mjs');
|
||||
configPath = path.join(tempDir, 'config.json');
|
||||
const longDescription = 'Large schema tool description. '.repeat(40);
|
||||
const longDescription = 'Large schema tool description. '.repeat(30);
|
||||
|
||||
await fs.writeFile(
|
||||
serverScriptPath,
|
||||
@ -211,25 +131,10 @@ await server.connect(transport);
|
||||
const result = await runCli(['list', 'force-exit', '--schema', '--output', 'json'], configPath);
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
expect(Buffer.byteLength(result.stdout)).toBeGreaterThan(64 * 1024);
|
||||
expect(Buffer.byteLength(result.stdout)).toBeGreaterThan(8192);
|
||||
|
||||
const payload = JSON.parse(result.stdout) as { tools: Array<{ name: string }> };
|
||||
expect(payload.tools).toHaveLength(65);
|
||||
expect(payload.tools.at(-1)?.name).toBe('tool_63');
|
||||
}, 20000);
|
||||
|
||||
itWithMutableStdoutInternals(
|
||||
'does not fail when stdout reports EPIPE during force exit cleanup',
|
||||
async () => {
|
||||
const result = await runCliWithCleanupStdoutError(configPath, tempDir);
|
||||
if (result.code !== 0 || result.stderr !== '') {
|
||||
throw new Error(
|
||||
`cleanup stdout EPIPE command failed with code ${result.code}; stdout bytes=${Buffer.byteLength(result.stdout)}; stderr=${JSON.stringify(result.stderr)}`
|
||||
);
|
||||
}
|
||||
expect(result.code).toBe(0);
|
||||
expect(result.stderr).toBe('');
|
||||
},
|
||||
20000
|
||||
);
|
||||
});
|
||||
|
||||
@ -7,20 +7,45 @@ import { metadataPathForArtifact, readCliMetadata } from '../src/cli-metadata.js
|
||||
describe('readCliMetadata', () => {
|
||||
it('prefers embedded metadata over stale sidecar metadata', async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-metadata-'));
|
||||
const artifact = path.join(tempDir, 'artifact');
|
||||
const artifact = path.join(tempDir, process.platform === 'win32' ? 'artifact.exe' : 'artifact');
|
||||
const embedded = metadataPayload('embedded');
|
||||
const sidecar = metadataPayload('sidecar');
|
||||
await fs.writeFile(
|
||||
artifact,
|
||||
`#!/usr/bin/env node\nconsole.log(${JSON.stringify(JSON.stringify(embedded))});\n`,
|
||||
'utf8'
|
||||
);
|
||||
await fs.chmod(artifact, 0o755);
|
||||
const previousEmbeddedMetadata = process.env.MCPORTER_TEST_EMBEDDED_METADATA;
|
||||
const previousNodeOptions = process.env.NODE_OPTIONS;
|
||||
process.env.MCPORTER_TEST_EMBEDDED_METADATA = JSON.stringify(embedded);
|
||||
if (process.platform === 'win32') {
|
||||
const preload = path.join(tempDir, 'inspect-preload.cjs');
|
||||
await fs.copyFile(process.execPath, artifact);
|
||||
await fs.writeFile(
|
||||
preload,
|
||||
'console.log(process.env.MCPORTER_TEST_EMBEDDED_METADATA); process.exit(0);\n',
|
||||
'utf8'
|
||||
);
|
||||
const requirePath = preload.replaceAll(path.sep, path.posix.sep);
|
||||
process.env.NODE_OPTIONS = `${previousNodeOptions ? `${previousNodeOptions} ` : ''}--require ${requirePath}`;
|
||||
} else {
|
||||
const artifactContent = '#!/usr/bin/env node\nconsole.log(process.env.MCPORTER_TEST_EMBEDDED_METADATA);\n';
|
||||
await fs.writeFile(artifact, artifactContent, 'utf8');
|
||||
await fs.chmod(artifact, 0o755);
|
||||
}
|
||||
await fs.writeFile(metadataPathForArtifact(artifact), JSON.stringify(sidecar), 'utf8');
|
||||
|
||||
await expect(readCliMetadata(artifact)).resolves.toMatchObject({
|
||||
server: { name: 'embedded' },
|
||||
});
|
||||
try {
|
||||
await expect(readCliMetadata(artifact)).resolves.toMatchObject({
|
||||
server: { name: 'embedded' },
|
||||
});
|
||||
} finally {
|
||||
if (previousEmbeddedMetadata === undefined) {
|
||||
delete process.env.MCPORTER_TEST_EMBEDDED_METADATA;
|
||||
} else {
|
||||
process.env.MCPORTER_TEST_EMBEDDED_METADATA = previousEmbeddedMetadata;
|
||||
}
|
||||
if (previousNodeOptions === undefined) {
|
||||
delete process.env.NODE_OPTIONS;
|
||||
} else {
|
||||
process.env.NODE_OPTIONS = previousNodeOptions;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -5,7 +5,12 @@ import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ServerDefinition } from '../src/config.js';
|
||||
import { __testProcessRequest, isDaemonResponding, metadataMatches } from '../src/daemon/host.js';
|
||||
import {
|
||||
__testProcessRequest,
|
||||
cleanupDaemonArtifactsIfOwned,
|
||||
isDaemonResponding,
|
||||
metadataMatches,
|
||||
} from '../src/daemon/host.js';
|
||||
import type { DaemonRequest } from '../src/daemon/protocol.js';
|
||||
import type { Runtime } from '../src/runtime.js';
|
||||
|
||||
@ -246,6 +251,41 @@ describe('metadataMatches', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('daemon artifact cleanup', () => {
|
||||
let dir: string;
|
||||
let metadataPath: string;
|
||||
let socketPath: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'mcporter-cleanup-'));
|
||||
metadataPath = path.join(dir, 'daemon.json');
|
||||
socketPath = path.join(dir, 'daemon.sock');
|
||||
await fs.writeFile(socketPath, 'socket', 'utf8');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('removes artifacts still owned by the stopping daemon', async () => {
|
||||
await fs.writeFile(metadataPath, JSON.stringify({ pid: 4321, socketPath }), 'utf8');
|
||||
|
||||
await cleanupDaemonArtifactsIfOwned({ metadataPath, socketPath }, 4321);
|
||||
|
||||
await expect(fs.access(metadataPath)).rejects.toThrow();
|
||||
await expect(fs.access(socketPath)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('preserves artifacts replaced by a newer daemon', async () => {
|
||||
await fs.writeFile(metadataPath, JSON.stringify({ pid: 9876, socketPath }), 'utf8');
|
||||
|
||||
await cleanupDaemonArtifactsIfOwned({ metadataPath, socketPath }, 4321);
|
||||
|
||||
await expect(fs.access(metadataPath)).resolves.toBeUndefined();
|
||||
await expect(fs.access(socketPath)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function createRuntimeDouble(): Pick<Runtime, 'callTool' | 'listTools'> {
|
||||
return {
|
||||
callTool: vi.fn().mockResolvedValue({ ok: true }),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user