server.ts•5.9 kB
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { access, stat } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import path from "node:path";
import process from "node:process";
const SwiftToolInputSchema = z
.object({
packagePath: z
.string()
.min(1, "packagePath must not be empty")
.describe("Absolute or relative path to the Swift package directory.")
.optional(),
swiftArgs: z
.array(z.string())
.describe("Additional arguments forwarded to `swift test`.")
.optional()
})
.describe("Parameters for running `swift test`.");
type SwiftToolInput = z.infer<typeof SwiftToolInputSchema>;
const server = new McpServer({
name: "swift-test-mcp-server",
version: "0.1.0"
});
server.registerTool(
"swift-test",
({
title: "Run swift test",
description: "Execute `swift test` inside the specified Swift package directory."
} as any),
async (rawInput) => {
const input = SwiftToolInputSchema.parse(rawInput ?? {});
try {
const result = await runSwiftTest(input);
const text = formatResult(result);
return {
content: [
{
type: "text",
text
}
],
isError: result.exitCode !== 0
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `swift test failed before execution: ${message}`
}
],
isError: true
};
}
}
);
async function runSwiftTest(input: SwiftToolInput) {
const packageRoot = await resolvePackageRoot(input.packagePath);
const extraArgs = input.swiftArgs ?? [];
const command = ["swift", "test", ...extraArgs];
const startedAt = process.hrtime.bigint();
const subprocess = Bun.spawn(command, {
cwd: packageRoot,
stdout: "pipe",
stderr: "pipe"
});
const stdoutPromise = subprocess.stdout
? new Response(subprocess.stdout).text()
: Promise.resolve("");
const stderrPromise = subprocess.stderr
? new Response(subprocess.stderr).text()
: Promise.resolve("");
const [stdout, stderr, exitCode] = await Promise.all([
stdoutPromise,
stderrPromise,
subprocess.exited
]);
const finishedAt = process.hrtime.bigint();
const durationMs = Number(finishedAt - startedAt) / 1_000_000;
return {
exitCode,
stdout,
stderr,
cwd: packageRoot,
command,
durationMs
};
}
async function resolvePackageRoot(inputPath?: string) {
const candidate = path.resolve(inputPath ?? process.cwd());
await assertDirectory(candidate);
await assertPackageManifest(candidate);
return candidate;
}
async function assertDirectory(directory: string) {
try {
await access(directory, fsConstants.R_OK | fsConstants.X_OK);
const fileStats = await stat(directory);
if (!fileStats.isDirectory()) {
throw new Error();
}
} catch {
throw new Error(`Path is not an accessible directory: ${directory}`);
}
}
async function assertPackageManifest(directory: string) {
const manifest = path.join(directory, "Package.swift");
try {
await access(manifest, fsConstants.R_OK);
} catch {
throw new Error(`No Package.swift manifest found at ${manifest}`);
}
}
function formatResult(result: {
exitCode: number;
stdout: string;
stderr: string;
cwd: string;
command: string[];
durationMs: number;
}) {
const headerLines = [
`command: ${result.command.join(" ")}`,
`cwd: ${result.cwd}`,
`exitCode: ${result.exitCode}`,
`durationMs: ${result.durationMs.toFixed(2)}`
];
const stdout = result.stdout.trim().length > 0 ? result.stdout : "(no stdout)";
const stderr = result.stderr.trim().length > 0 ? result.stderr : "(no stderr)";
return `${headerLines.join("\n")}\n\nstdout:\n${stdout}\n\nstderr:\n${stderr}`;
}
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
// Replace the SDK's default tools/call handler to bypass its schema conversion.
// We still advertise the tool via registerTool (without inputSchema) above.
const CallToolRequestSchema = z.object({
method: z.literal("tools/call"),
params: z
.object({
name: z.string(),
arguments: z.record(z.string(), z.unknown()).optional(),
_meta: z.any().optional()
})
.passthrough()
});
try {
// Remove any default handler the SDK installed for tools/call.
server.server.removeRequestHandler("tools/call");
} catch {}
server.server.setRequestHandler(CallToolRequestSchema as any, async (request: any) => {
if (request.params?.name !== "swift-test") {
// Let the SDK handle other tools if present in the future.
throw new Error(`Unknown tool: ${request.params?.name ?? "(missing)"}`);
}
const input = SwiftToolInputSchema.parse(request.params?.arguments ?? {});
try {
const result = await runSwiftTest(input);
const text = formatResult(result);
return {
content: [
{
type: "text",
text
}
],
isError: result.exitCode !== 0
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `swift test failed before execution: ${message}`
}
],
isError: true
};
}
});
}
main().catch((error) => {
const message = error instanceof Error ? error.stack ?? error.message : String(error);
console.error(`Fatal error while starting MCP server: ${message}`);
process.exit(1);
});