metadata
This commit is contained in:
+101
-74
@@ -6,13 +6,7 @@ import { FileService } from "./file-service.js";
|
||||
import { ProcessManager } from "./process-manager.js";
|
||||
import { runScript } from "./script-runner.js";
|
||||
import { runTool } from "./tool-result.js";
|
||||
|
||||
const fullAccessAnnotations = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: false,
|
||||
openWorldHint: true,
|
||||
};
|
||||
import { TOOL_ANNOTATIONS, toolAuthMetadata } from "./tool-metadata.js";
|
||||
|
||||
function processResult(result: Awaited<ReturnType<ProcessManager["read"]>>): Record<string, unknown> {
|
||||
return {
|
||||
@@ -27,17 +21,45 @@ export function registerExecTools(
|
||||
processManager: ProcessManager,
|
||||
fileService: FileService,
|
||||
): void {
|
||||
const authMetadata = toolAuthMetadata(config);
|
||||
const environmentSchema = z
|
||||
.record(z.string(), z.string())
|
||||
.optional()
|
||||
.describe("Environment variables added to or overriding the server process environment.");
|
||||
const sessionIdSchema = z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe("Process session ID returned by exec_command or run_script.");
|
||||
const afterSeqSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.default(0)
|
||||
.describe(
|
||||
"Return only retained output chunks whose sequence number is greater than this value. Use the previous nextSeq value; zero starts with the earliest retained output.",
|
||||
);
|
||||
const timeoutSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.default(0)
|
||||
.describe(
|
||||
"Milliseconds before marking the process timed out and sending SIGTERM. Zero disables the timeout. A process still running five seconds after SIGTERM is sent SIGKILL.",
|
||||
);
|
||||
const maxOutputBytesSchema = z
|
||||
.number()
|
||||
.int()
|
||||
.min(16 * 1024)
|
||||
.max(config.maxOutputBytes)
|
||||
.default(config.maxOutputBytes)
|
||||
.describe("Maximum retained process-output bytes included in this result.");
|
||||
|
||||
server.registerTool(
|
||||
"exec_command",
|
||||
{
|
||||
title: "Execute command",
|
||||
description:
|
||||
"Run an unrestricted shell command on the host. The command inherits the MCP server's full OS permissions, environment, filesystem, and network access. Returns output immediately when complete or a process session ID when still running.",
|
||||
"Run an unrestricted shell command on the host. The command inherits the MCP server's full OS permissions, environment, filesystem, and network access. A successful start always returns a process session ID, current process state, and retained output; poll a running process with read_process or write_stdin.",
|
||||
inputSchema: {
|
||||
cmd: z.string().min(1).describe("Shell command or script to execute."),
|
||||
workdir: z
|
||||
@@ -54,28 +76,20 @@ export function registerExecTools(
|
||||
.describe("Use login-shell semantics (-lc) instead of -c."),
|
||||
env: environmentSchema,
|
||||
stdin: z.string().optional().describe("Initial text written to stdin after spawn."),
|
||||
timeoutMs: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.default(0)
|
||||
.describe("Maximum runtime in milliseconds. Zero means no timeout."),
|
||||
timeoutMs: timeoutSchema,
|
||||
yieldTimeMs: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(30_000)
|
||||
.default(10_000)
|
||||
.describe("How long to wait for output before returning a running session."),
|
||||
maxOutputBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(16 * 1024)
|
||||
.max(config.maxOutputBytes)
|
||||
.default(config.maxOutputBytes)
|
||||
.describe("Maximum output bytes returned by this call."),
|
||||
.describe(
|
||||
"How long to wait for the process to exit before returning its current state. Zero returns immediately.",
|
||||
),
|
||||
maxOutputBytes: maxOutputBytesSchema,
|
||||
},
|
||||
annotations: fullAccessAnnotations,
|
||||
annotations: TOOL_ANNOTATIONS.destructiveNonIdempotentOpen,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({
|
||||
cmd,
|
||||
@@ -113,7 +127,7 @@ export function registerExecTools(
|
||||
{
|
||||
title: "Run script",
|
||||
description:
|
||||
"Write a supplied script to a temporary executable file and run it with Bash, sh, Node.js, Python, or an arbitrary interpreter. Execution is unrestricted and has the MCP server's full host permissions.",
|
||||
"Write a supplied script to a temporary executable file and run it with Bash, sh, Node.js, Python, or an arbitrary interpreter. Execution is unrestricted and has the MCP server's full host permissions. A successful start always returns a process session ID, current process state, and retained output.",
|
||||
inputSchema: {
|
||||
runtime: z
|
||||
.enum(["bash", "sh", "node", "python", "custom"])
|
||||
@@ -135,30 +149,26 @@ export function registerExecTools(
|
||||
.default([])
|
||||
.describe("Arguments placed before the temporary script path."),
|
||||
stdin: z.string().optional().describe("Initial text written to the script stdin."),
|
||||
timeoutMs: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.default(0)
|
||||
.describe("Maximum runtime in milliseconds. Zero means no timeout."),
|
||||
timeoutMs: timeoutSchema,
|
||||
yieldTimeMs: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(30_000)
|
||||
.default(10_000),
|
||||
maxOutputBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(16 * 1024)
|
||||
.max(config.maxOutputBytes)
|
||||
.default(config.maxOutputBytes),
|
||||
.default(10_000)
|
||||
.describe(
|
||||
"How long to wait for the script process to exit before returning its current state. Zero returns immediately.",
|
||||
),
|
||||
maxOutputBytes: maxOutputBytesSchema,
|
||||
keepScript: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Keep the temporary script after the process exits and return its path."),
|
||||
.describe(
|
||||
"Keep the temporary script after process exit and include its path in the result. When false, the temporary directory is removed after exit.",
|
||||
),
|
||||
},
|
||||
annotations: fullAccessAnnotations,
|
||||
annotations: TOOL_ANNOTATIONS.destructiveNonIdempotentOpen,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({
|
||||
runtime,
|
||||
@@ -198,21 +208,31 @@ export function registerExecTools(
|
||||
{
|
||||
title: "Write to process stdin",
|
||||
description:
|
||||
"Write text to an existing process session, optionally close stdin, then return new output.",
|
||||
"Write text to an existing process session, optionally close stdin, then return current process state and retained output with sequence numbers greater than afterSeq.",
|
||||
inputSchema: {
|
||||
sessionId: z.string().uuid(),
|
||||
chars: z.string().default(""),
|
||||
closeStdin: z.boolean().default(false),
|
||||
afterSeq: z.number().int().min(0).default(0),
|
||||
yieldTimeMs: z.number().int().min(0).max(300_000).default(250),
|
||||
maxOutputBytes: z
|
||||
sessionId: sessionIdSchema,
|
||||
chars: z
|
||||
.string()
|
||||
.default("")
|
||||
.describe("Text to write to the process stdin. An empty value writes nothing."),
|
||||
closeStdin: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Close the process stdin after writing chars."),
|
||||
afterSeq: afterSeqSchema,
|
||||
yieldTimeMs: z
|
||||
.number()
|
||||
.int()
|
||||
.min(16 * 1024)
|
||||
.max(config.maxOutputBytes)
|
||||
.default(config.maxOutputBytes),
|
||||
.min(0)
|
||||
.max(300_000)
|
||||
.default(250)
|
||||
.describe(
|
||||
"When stdin remains open, wait this long for output or process exit. When closeStdin=true, wait this long for process exit before returning.",
|
||||
),
|
||||
maxOutputBytes: maxOutputBytesSchema,
|
||||
},
|
||||
annotations: fullAccessAnnotations,
|
||||
annotations: TOOL_ANNOTATIONS.destructiveNonIdempotentOpen,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({ sessionId, chars, closeStdin, afterSeq, yieldTimeMs, maxOutputBytes }) =>
|
||||
runTool(async () => {
|
||||
@@ -236,22 +256,21 @@ export function registerExecTools(
|
||||
description:
|
||||
"Poll a managed process for output and terminal state. Pass the previous nextSeq as afterSeq to receive only newer output.",
|
||||
inputSchema: {
|
||||
sessionId: z.string().uuid(),
|
||||
afterSeq: z.number().int().min(0).default(0),
|
||||
waitMs: z.number().int().min(0).max(300_000).default(1000),
|
||||
maxOutputBytes: z
|
||||
sessionId: sessionIdSchema,
|
||||
afterSeq: afterSeqSchema,
|
||||
waitMs: z
|
||||
.number()
|
||||
.int()
|
||||
.min(16 * 1024)
|
||||
.max(config.maxOutputBytes)
|
||||
.default(config.maxOutputBytes),
|
||||
},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
.min(0)
|
||||
.max(300_000)
|
||||
.default(1000)
|
||||
.describe(
|
||||
"How long to wait for output newer than afterSeq or for process exit. Zero returns immediately.",
|
||||
),
|
||||
maxOutputBytes: maxOutputBytesSchema,
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS.readOnlyClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({ sessionId, afterSeq, waitMs, maxOutputBytes }) =>
|
||||
runTool(async () =>
|
||||
@@ -270,13 +289,25 @@ export function registerExecTools(
|
||||
{
|
||||
title: "Terminate process",
|
||||
description:
|
||||
"Send a signal to a managed process tree. SIGTERM escalates to SIGKILL after graceMs if necessary.",
|
||||
"Send a signal to a managed process tree. When graceMs is greater than zero, SIGINT and SIGTERM escalate to SIGKILL if the process is still running after the grace period. The call may return while escalation is still pending.",
|
||||
inputSchema: {
|
||||
sessionId: z.string().uuid(),
|
||||
signal: z.enum(["SIGINT", "SIGTERM", "SIGKILL"]).default("SIGTERM"),
|
||||
graceMs: z.number().int().min(0).max(60_000).default(3000),
|
||||
sessionId: sessionIdSchema,
|
||||
signal: z
|
||||
.enum(["SIGINT", "SIGTERM", "SIGKILL"])
|
||||
.default("SIGTERM")
|
||||
.describe("Signal sent to the managed process tree."),
|
||||
graceMs: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(60_000)
|
||||
.default(3000)
|
||||
.describe(
|
||||
"For SIGINT or SIGTERM, milliseconds before SIGKILL escalation; zero disables escalation. The call waits at most one second before returning.",
|
||||
),
|
||||
},
|
||||
annotations: fullAccessAnnotations,
|
||||
annotations: TOOL_ANNOTATIONS.destructiveNonIdempotentClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({ sessionId, signal, graceMs }) =>
|
||||
runTool(async () =>
|
||||
@@ -290,12 +321,8 @@ export function registerExecTools(
|
||||
title: "List managed processes",
|
||||
description: "List running and recently completed process sessions.",
|
||||
inputSchema: {},
|
||||
annotations: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS.readOnlyClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async () => runTool(() => ({ processes: processManager.list() })),
|
||||
);
|
||||
|
||||
+188
-63
@@ -4,20 +4,7 @@ import * as z from "zod/v4";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { FileService } from "./file-service.js";
|
||||
import { runTool } from "./tool-result.js";
|
||||
|
||||
const readAnnotations = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
};
|
||||
|
||||
const writeAnnotations = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
};
|
||||
import { TOOL_ANNOTATIONS, toolAuthMetadata } from "./tool-metadata.js";
|
||||
|
||||
const cwdSchema = z
|
||||
.string()
|
||||
@@ -47,6 +34,8 @@ export function registerFileTools(
|
||||
config: AppConfig,
|
||||
files: FileService,
|
||||
): void {
|
||||
const authMetadata = toolAuthMetadata(config);
|
||||
|
||||
server.registerTool(
|
||||
"list_directory",
|
||||
{
|
||||
@@ -56,13 +45,37 @@ export function registerFileTools(
|
||||
inputSchema: {
|
||||
path: pathSchema,
|
||||
cwd: cwdSchema,
|
||||
recursive: z.boolean().default(false),
|
||||
maxDepth: z.number().int().min(0).max(100).default(8),
|
||||
maxEntries: z.number().int().min(1).max(50_000).default(1000),
|
||||
includeHidden: z.boolean().default(true),
|
||||
includeMetadata: z.boolean().default(false),
|
||||
recursive: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Descend into child directories without following directory symlinks."),
|
||||
maxDepth: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(100)
|
||||
.default(8)
|
||||
.describe(
|
||||
"Maximum directory depth below the requested root when recursive=true. Zero lists only direct children.",
|
||||
),
|
||||
maxEntries: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(50_000)
|
||||
.default(1000)
|
||||
.describe("Maximum total entries returned; truncated=true indicates the limit was reached."),
|
||||
includeHidden: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe("Include entries whose names begin with a dot."),
|
||||
includeMetadata: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Include size, Unix mode, and modification time for each entry."),
|
||||
},
|
||||
annotations: readAnnotations,
|
||||
annotations: TOOL_ANNOTATIONS.readOnlyClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({ path, cwd, recursive, maxDepth, maxEntries, includeHidden, includeMetadata }) =>
|
||||
runTool(() =>
|
||||
@@ -82,7 +95,8 @@ export function registerFileTools(
|
||||
title: "Inspect path",
|
||||
description: "Return metadata for any file, directory, or symbolic link.",
|
||||
inputSchema: { path: pathSchema, cwd: cwdSchema },
|
||||
annotations: readAnnotations,
|
||||
annotations: TOOL_ANNOTATIONS.readOnlyClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({ path, cwd }) => runTool(() => files.getInfo(path, cwd)),
|
||||
);
|
||||
@@ -96,16 +110,28 @@ export function registerFileTools(
|
||||
inputSchema: {
|
||||
path: pathSchema,
|
||||
cwd: cwdSchema,
|
||||
offset: z.number().int().min(0).default(0),
|
||||
offset: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.default(0)
|
||||
.describe("Starting byte offset. A value past end of file is clamped to end of file."),
|
||||
maxBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(config.maxFileChunkBytes)
|
||||
.default(Math.min(256 * 1024, config.maxFileChunkBytes)),
|
||||
encoding: z.enum(["utf8", "base64"]).default("utf8"),
|
||||
.default(Math.min(256 * 1024, config.maxFileChunkBytes))
|
||||
.describe(
|
||||
"Maximum raw bytes requested. UTF-8 mode may adjust the returned size at a character boundary.",
|
||||
),
|
||||
encoding: z
|
||||
.enum(["utf8", "base64"])
|
||||
.default("utf8")
|
||||
.describe("Return valid text as UTF-8 or arbitrary bytes as base64."),
|
||||
},
|
||||
annotations: readAnnotations,
|
||||
annotations: TOOL_ANNOTATIONS.readOnlyClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({ path, cwd, offset, maxBytes, encoding }) =>
|
||||
runTool(() => files.readFileChunk(path, cwd, offset, maxBytes, encoding)),
|
||||
@@ -120,13 +146,27 @@ export function registerFileTools(
|
||||
inputSchema: {
|
||||
path: pathSchema,
|
||||
cwd: cwdSchema,
|
||||
content: z.string(),
|
||||
encoding: z.enum(["utf8", "base64"]).default("utf8"),
|
||||
mode: z.enum(["overwrite", "append"]).default("overwrite"),
|
||||
createParents: z.boolean().default(true),
|
||||
fileMode: fileModeSchema,
|
||||
content: z
|
||||
.string()
|
||||
.describe("File content encoded according to encoding."),
|
||||
encoding: z
|
||||
.enum(["utf8", "base64"])
|
||||
.default("utf8")
|
||||
.describe("Interpret content as UTF-8 text or strictly validated base64."),
|
||||
mode: z
|
||||
.enum(["overwrite", "append"])
|
||||
.default("overwrite")
|
||||
.describe("Overwrite and truncate the file, or append content to its current end."),
|
||||
createParents: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe("Create missing parent directories before writing."),
|
||||
fileMode: fileModeSchema.describe(
|
||||
"Unix mode as an octal string, for example 0644. When provided, it is applied to both new and existing files.",
|
||||
),
|
||||
},
|
||||
annotations: writeAnnotations,
|
||||
annotations: TOOL_ANNOTATIONS.destructiveNonIdempotentClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({ path, cwd, content, encoding, mode, createParents, fileMode }) =>
|
||||
runTool(() =>
|
||||
@@ -151,12 +191,26 @@ export function registerFileTools(
|
||||
inputSchema: {
|
||||
path: pathSchema,
|
||||
cwd: cwdSchema,
|
||||
oldText: z.string().min(1),
|
||||
newText: z.string(),
|
||||
replaceAll: z.boolean().default(false),
|
||||
expectedOccurrences: z.number().int().min(0).optional(),
|
||||
oldText: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("Exact non-empty UTF-8 text to find."),
|
||||
newText: z.string().describe("Replacement UTF-8 text."),
|
||||
replaceAll: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Replace every occurrence instead of only the first occurrence."),
|
||||
expectedOccurrences: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.optional()
|
||||
.describe(
|
||||
"Required total oldText occurrence count before editing. When omitted, the default is one for a single replacement and the observed count for replaceAll=true.",
|
||||
),
|
||||
},
|
||||
annotations: writeAnnotations,
|
||||
annotations: TOOL_ANNOTATIONS.destructiveNonIdempotentClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({ path, cwd, oldText, newText, replaceAll, expectedOccurrences }) =>
|
||||
runTool(() =>
|
||||
@@ -180,11 +234,23 @@ export function registerFileTools(
|
||||
inputSchema: {
|
||||
patch: z.string().min(1).describe("Standard unified diff text."),
|
||||
cwd: cwdSchema,
|
||||
checkOnly: z.boolean().default(false),
|
||||
reverse: z.boolean().default(false),
|
||||
threeWay: z.boolean().default(false),
|
||||
checkOnly: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Validate the patch with git apply --check without applying it."),
|
||||
reverse: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Reverse the patch before checking or applying it."),
|
||||
threeWay: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
"Pass --3way to git apply. This requires applicable Git index data and may leave conflict markers when application conflicts.",
|
||||
),
|
||||
},
|
||||
annotations: writeAnnotations,
|
||||
annotations: TOOL_ANNOTATIONS.destructiveNonIdempotentClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({ patch, cwd, checkOnly, reverse, threeWay }) =>
|
||||
runTool(() => files.applyPatch(patch, cwd, { checkOnly, reverse, threeWay })),
|
||||
@@ -199,12 +265,28 @@ export function registerFileTools(
|
||||
inputSchema: {
|
||||
path: pathSchema,
|
||||
cwd: cwdSchema,
|
||||
dataBase64: z.string(),
|
||||
offset: z.number().int().min(0).default(0),
|
||||
truncate: z.boolean().default(false),
|
||||
createParents: z.boolean().default(true),
|
||||
dataBase64: z
|
||||
.string()
|
||||
.describe("Strictly validated base64 data for this chunk."),
|
||||
offset: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.default(0)
|
||||
.describe(
|
||||
"Exact byte offset at which to write the chunk. Writing past end of file may create a sparse zero-filled gap.",
|
||||
),
|
||||
truncate: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Truncate the file to zero bytes before writing this chunk."),
|
||||
createParents: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe("Create missing parent directories before opening the file."),
|
||||
},
|
||||
annotations: writeAnnotations,
|
||||
annotations: TOOL_ANNOTATIONS.destructiveIdempotentClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({ path, cwd, dataBase64, offset, truncate, createParents }) =>
|
||||
runTool(() =>
|
||||
@@ -221,15 +303,22 @@ export function registerFileTools(
|
||||
inputSchema: {
|
||||
path: pathSchema,
|
||||
cwd: cwdSchema,
|
||||
offset: z.number().int().min(0).default(0),
|
||||
offset: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.default(0)
|
||||
.describe("Starting byte offset. A value past end of file is clamped to end of file."),
|
||||
maxBytes: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(config.maxFileChunkBytes)
|
||||
.default(config.maxFileChunkBytes),
|
||||
.default(config.maxFileChunkBytes)
|
||||
.describe("Maximum raw file bytes encoded into this base64 chunk."),
|
||||
},
|
||||
annotations: readAnnotations,
|
||||
annotations: TOOL_ANNOTATIONS.readOnlyClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({ path, cwd, offset, maxBytes }) =>
|
||||
runTool(() => files.downloadChunk(path, cwd, offset, maxBytes)),
|
||||
@@ -243,10 +332,18 @@ export function registerFileTools(
|
||||
inputSchema: {
|
||||
path: pathSchema,
|
||||
cwd: cwdSchema,
|
||||
recursive: z.boolean().default(true),
|
||||
mode: fileModeSchema,
|
||||
recursive: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe(
|
||||
"When true, create missing parent directories and succeed when the target directory already exists.",
|
||||
),
|
||||
mode: fileModeSchema.describe(
|
||||
"Creation mode as an octal string, for example 0755. Existing directories are not chmodded.",
|
||||
),
|
||||
},
|
||||
annotations: writeAnnotations,
|
||||
annotations: TOOL_ANNOTATIONS.additiveIdempotentClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({ path, cwd, recursive, mode }) =>
|
||||
runTool(() => files.makeDirectory(path, cwd, recursive, parseMode(mode))),
|
||||
@@ -261,10 +358,19 @@ export function registerFileTools(
|
||||
sourcePath: pathSchema,
|
||||
destinationPath: pathSchema,
|
||||
cwd: cwdSchema,
|
||||
recursive: z.boolean().default(true),
|
||||
force: z.boolean().default(true),
|
||||
recursive: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe("Allow directory trees to be copied. A directory source requires true."),
|
||||
force: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe(
|
||||
"Allow existing destination entries to be overwritten or merged. When false, fail if the destination path already exists.",
|
||||
),
|
||||
},
|
||||
annotations: writeAnnotations,
|
||||
annotations: TOOL_ANNOTATIONS.destructiveIdempotentClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({ sourcePath, destinationPath, cwd, recursive, force }) =>
|
||||
runTool(() => files.copyPath(sourcePath, destinationPath, cwd, recursive, force)),
|
||||
@@ -279,9 +385,13 @@ export function registerFileTools(
|
||||
sourcePath: pathSchema,
|
||||
destinationPath: pathSchema,
|
||||
cwd: cwdSchema,
|
||||
overwrite: z.boolean().default(false),
|
||||
overwrite: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Replace an existing destination path. When false, an existing destination is an error."),
|
||||
},
|
||||
annotations: writeAnnotations,
|
||||
annotations: TOOL_ANNOTATIONS.destructiveIdempotentClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({ sourcePath, destinationPath, cwd, overwrite }) =>
|
||||
runTool(() => files.movePath(sourcePath, destinationPath, cwd, overwrite)),
|
||||
@@ -296,10 +406,17 @@ export function registerFileTools(
|
||||
inputSchema: {
|
||||
path: pathSchema,
|
||||
cwd: cwdSchema,
|
||||
recursive: z.boolean().default(false),
|
||||
force: z.boolean().default(false),
|
||||
recursive: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Recursively remove a directory and all of its contents."),
|
||||
force: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe("Ignore a missing target. This does not suppress other filesystem errors."),
|
||||
},
|
||||
annotations: writeAnnotations,
|
||||
annotations: TOOL_ANNOTATIONS.destructiveIdempotentClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({ path, cwd, recursive, force }) =>
|
||||
runTool(() => files.removePath(path, cwd, recursive, force)),
|
||||
@@ -313,9 +430,13 @@ export function registerFileTools(
|
||||
inputSchema: {
|
||||
path: pathSchema,
|
||||
cwd: cwdSchema,
|
||||
mode: z.string().regex(/^(?:0o)?[0-7]{3,4}$/),
|
||||
mode: z
|
||||
.string()
|
||||
.regex(/^(?:0o)?[0-7]{3,4}$/)
|
||||
.describe("Unix mode as a three- or four-digit octal string, optionally prefixed with 0o."),
|
||||
},
|
||||
annotations: writeAnnotations,
|
||||
annotations: TOOL_ANNOTATIONS.destructiveIdempotentClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({ path, cwd, mode }) =>
|
||||
runTool(() => files.changeMode(path, cwd, parseMode(mode) ?? 0)),
|
||||
@@ -329,9 +450,13 @@ export function registerFileTools(
|
||||
inputSchema: {
|
||||
path: pathSchema,
|
||||
cwd: cwdSchema,
|
||||
algorithm: z.enum(["sha256", "sha512", "md5"]).default("sha256"),
|
||||
algorithm: z
|
||||
.enum(["sha256", "sha512", "md5"])
|
||||
.default("sha256")
|
||||
.describe("Digest algorithm used to hash the file bytes."),
|
||||
},
|
||||
annotations: readAnnotations,
|
||||
annotations: TOOL_ANNOTATIONS.readOnlyClosed,
|
||||
_meta: authMetadata,
|
||||
},
|
||||
async ({ path, cwd, algorithm }) =>
|
||||
runTool(() => files.hashFile(path, cwd, algorithm)),
|
||||
|
||||
@@ -33,7 +33,6 @@ export function createMcpServer(config: AppConfig, services: McpServices): McpSe
|
||||
{
|
||||
name: "cokacremote",
|
||||
version: "0.1.0",
|
||||
...(config.publicUrl ? { websiteUrl: config.publicUrl } : {}),
|
||||
},
|
||||
{
|
||||
instructions:
|
||||
|
||||
+2
-1
@@ -24,8 +24,9 @@ import type { Request, Response } from "express";
|
||||
|
||||
import { tokensEqual } from "./auth.js";
|
||||
import type { AppConfig } from "./config.js";
|
||||
import { OAUTH_SCOPES } from "./tool-metadata.js";
|
||||
|
||||
export const OAUTH_SCOPES = ["mcp:tools"] as const;
|
||||
export { OAUTH_SCOPES };
|
||||
|
||||
interface StoredToken {
|
||||
type: "access" | "refresh" | "used_refresh";
|
||||
|
||||
+42
-4
@@ -5,6 +5,7 @@ import { isAscii } from "node:buffer";
|
||||
import { errorMessage } from "./errors.js";
|
||||
|
||||
const OUTPUT_CHUNK_BYTES = 16 * 1024;
|
||||
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
||||
|
||||
export type ProcessOutputStream = "stdout" | "stderr";
|
||||
|
||||
@@ -34,6 +35,7 @@ interface ManagedProcess {
|
||||
waiters: Set<() => void>;
|
||||
exitWaiters: Set<() => void>;
|
||||
timeoutHandle: NodeJS.Timeout | undefined;
|
||||
retentionHandle: NodeJS.Timeout | undefined;
|
||||
cleanup: (() => Promise<void>) | undefined;
|
||||
}
|
||||
|
||||
@@ -216,6 +218,7 @@ export class ProcessManager {
|
||||
waiters: new Set(),
|
||||
exitWaiters: new Set(),
|
||||
timeoutHandle: undefined,
|
||||
retentionHandle: undefined,
|
||||
cleanup: request.cleanup,
|
||||
};
|
||||
this.#processes.set(sessionId, managed);
|
||||
@@ -418,7 +421,6 @@ export class ProcessManager {
|
||||
endedAt: string | undefined;
|
||||
exitCode: number | null | undefined;
|
||||
}> {
|
||||
this.prune();
|
||||
return [...this.#processes.values()].map((managed) => ({
|
||||
sessionId: managed.sessionId,
|
||||
pid: managed.child.pid,
|
||||
@@ -436,9 +438,9 @@ export class ProcessManager {
|
||||
|
||||
prune(): void {
|
||||
const cutoff = Date.now() - this.#options.processRetentionMs;
|
||||
for (const [sessionId, managed] of this.#processes) {
|
||||
for (const managed of this.#processes.values()) {
|
||||
if (managed.endedAt !== undefined && managed.endedAt < cutoff) {
|
||||
this.#processes.delete(sessionId);
|
||||
this.#forget(managed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -471,7 +473,7 @@ export class ProcessManager {
|
||||
) {
|
||||
const managed = completed.shift();
|
||||
if (managed) {
|
||||
this.#processes.delete(managed.sessionId);
|
||||
this.#forget(managed);
|
||||
}
|
||||
}
|
||||
if (this.#processes.size >= this.#options.maxProcesses) {
|
||||
@@ -574,6 +576,42 @@ export class ProcessManager {
|
||||
managed.error ??= `Cleanup failed: ${errorMessage(error)}`;
|
||||
});
|
||||
}
|
||||
this.#scheduleRetention(managed);
|
||||
}
|
||||
|
||||
#scheduleRetention(managed: ManagedProcess): void {
|
||||
const expiresAt = (managed.endedAt ?? Date.now()) + this.#options.processRetentionMs;
|
||||
const expireOrReschedule = () => {
|
||||
managed.retentionHandle = undefined;
|
||||
if (this.#processes.get(managed.sessionId) !== managed) {
|
||||
return;
|
||||
}
|
||||
const remainingMs = expiresAt - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
this.#processes.delete(managed.sessionId);
|
||||
return;
|
||||
}
|
||||
managed.retentionHandle = setTimeout(
|
||||
expireOrReschedule,
|
||||
Math.min(remainingMs, MAX_TIMER_DELAY_MS),
|
||||
);
|
||||
managed.retentionHandle.unref();
|
||||
};
|
||||
managed.retentionHandle = setTimeout(
|
||||
expireOrReschedule,
|
||||
Math.min(this.#options.processRetentionMs, MAX_TIMER_DELAY_MS),
|
||||
);
|
||||
managed.retentionHandle.unref();
|
||||
}
|
||||
|
||||
#forget(managed: ManagedProcess): void {
|
||||
if (managed.retentionHandle) {
|
||||
clearTimeout(managed.retentionHandle);
|
||||
managed.retentionHandle = undefined;
|
||||
}
|
||||
if (this.#processes.get(managed.sessionId) === managed) {
|
||||
this.#processes.delete(managed.sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
#notify(managed: ManagedProcess): void {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import type { AppConfig } from "./config.js";
|
||||
|
||||
export const OAUTH_SCOPES = ["mcp:tools"] as const;
|
||||
|
||||
export const TOOL_ANNOTATIONS = {
|
||||
readOnlyClosed: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
additiveIdempotentClosed: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
destructiveIdempotentClosed: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
destructiveNonIdempotentClosed: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
destructiveNonIdempotentOpen: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: false,
|
||||
openWorldHint: true,
|
||||
},
|
||||
} as const satisfies Record<string, ToolAnnotations>;
|
||||
|
||||
type ToolSecurityScheme = { type: "oauth2"; scopes: string[] };
|
||||
|
||||
/**
|
||||
* OpenAI's tool auth extension currently supports only noauth and OAuth 2.0.
|
||||
* Static-bearer-only and built-in-auth-disabled deployments intentionally omit
|
||||
* securitySchemes instead of inferring an external authentication policy that
|
||||
* the process cannot observe. Authentication, if any, remains a connection- or
|
||||
* deployment-level concern.
|
||||
*/
|
||||
export function toolAuthMetadata(
|
||||
config: AppConfig,
|
||||
): { securitySchemes: ToolSecurityScheme[] } | undefined {
|
||||
if (config.oauthEnabled) {
|
||||
return {
|
||||
securitySchemes: [
|
||||
{ type: "oauth2", scopes: [...OAUTH_SCOPES] },
|
||||
],
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user