This commit is contained in:
kst
2026-08-26 03:33:29 +09:00
parent 400f1d3e91
commit 4fc70e9697
10 changed files with 610 additions and 156 deletions
+28 -12
View File
@@ -47,14 +47,14 @@ The server starts on port `3000` by default.
- MCP endpoint: `http://127.0.0.1:3000/mcp`
- Health check: `http://127.0.0.1:3000/health`
For a real remote ChatGPT connection, you will normally also need:
For a remote MCP connection over the public internet, you will normally also need:
1. A public HTTPS domain such as `https://mcp.example.com`
2. Nginx or another reverse proxy in front of the Node.js service
3. OAuth enabled for ChatGPT, or a Bearer token for clients that support one
4. The MCP URL added in ChatGPT, for example `https://mcp.example.com/mcp`
3. An authentication method supported by the client, such as OAuth 2.1 or a Bearer token
4. The MCP URL added to the client, for example `https://mcp.example.com/mcp`
The full deployment and ChatGPT connection steps are explained later in this README.
The full deployment steps and a ChatGPT-specific connection example are explained later in this README.
## What can it do?
@@ -73,10 +73,10 @@ Internally, these actions are provided through 20 MCP tools for shell execution,
With `cokacremote`:
1. ChatGPT sends an MCP request over HTTPS.
1. An MCP client sends an MCP request over HTTPS.
2. `cokacremote` checks authentication.
3. It runs the requested tool directly on the host server.
4. The command output or file-operation result is returned to ChatGPT.
4. The command output or file-operation result is returned to the client.
The MCP transport is stateless, but long-running command sessions are kept in memory so they can be polled or controlled across multiple requests.
@@ -85,7 +85,7 @@ The MCP transport is stateless, but long-running command sessions are kept in me
- Shell commands, complete scripts, builds, tests, package installation, Git, and service management
- Output polling, stdin delivery, and termination control for long-running processes
- Read, write, edit, transfer, and delete host files, including absolute paths
- Built-in static Bearer authentication and OAuth 2.1/DCR/PKCE for ChatGPT
- Built-in static Bearer authentication and OAuth 2.1/DCR/PKCE for compatible MCP clients
- Stateless JSON transport per request, per-process output retention, and response size limits
- systemd and Nginx deployment examples for Linux VPS/EC2 environments
@@ -111,6 +111,20 @@ Relative paths are resolved from `MCP_DEFAULT_CWD`, while absolute paths and `~/
The server provides 20 tools in total. `remove_path` permanently deletes targets without using a trash folder, and `apply_patch` uses the host's `git apply --unsafe-paths`.
### Tool safety and authentication metadata
Every tool explicitly publishes all four MCP safety hints. The values describe the strongest behavior available through that tool, including optional arguments such as `write_file.mode="append"` and `copy_path.force=true`.
| Behavior | Tools | `readOnlyHint` | `destructiveHint` | `idempotentHint` | `openWorldHint` |
|---|---|---:|---:|---:|---:|
| Read-only, closed world | `list_directory`, `stat_path`, `read_file`, `download_file`, `hash_file`, `read_process`, `list_processes` | `true` | `false` | `true` | `false` |
| Additive and idempotent | `make_directory` | `false` | `false` | `true` | `false` |
| Destructive and idempotent | `upload_file`, `copy_path`, `move_path`, `remove_path`, `chmod_path` | `false` | `true` | `true` | `false` |
| Destructive and non-idempotent, closed world | `write_file`, `replace_in_file`, `apply_patch`, `terminate_process` | `false` | `true` | `false` | `false` |
| Destructive and non-idempotent, open world | `exec_command`, `run_script`, `write_stdin` | `false` | `true` | `false` | `true` |
These annotations are advisory client metadata, not access control. They do not replace authentication, which is enforced by the built-in HTTP layer or an upstream gateway when configured. When built-in OAuth is enabled, every tool advertises the `oauth2` security scheme with the `mcp:tools` scope through `_meta.securitySchemes`. Static-Bearer-only and built-in-auth-disabled (`MCP_ALLOW_NO_AUTH`) deployments intentionally omit this OpenAI extension: a pre-shared Bearer token is neither `noauth` nor `oauth2`, while disabling built-in authentication may represent a deliberately anonymous endpoint, upstream authentication, or private-network access. The process cannot infer that external policy honestly, so authentication, if any, remains connection- or deployment-level.
### File reading and transfer rules
- `offset`, `bytesRead`, and `nextOffset` returned by `read_file` are all byte offsets or byte counts.
@@ -144,7 +158,9 @@ The server provides 20 tools in total. `remove_path` permanently deletes targets
The Quick Start above is enough to run a normal local instance. If you are changing the source code itself, development mode automatically watches the TypeScript entry point:
```bash
MCP_AUTH_TOKEN=development-token npm run dev
export MCP_HOST=127.0.0.1
export MCP_AUTH_TOKEN="$(openssl rand -hex 32)"
npm run dev
```
## Authentication
@@ -155,7 +171,7 @@ When `MCP_AUTH_TOKEN` is set, every MCP request requires the following header:
Authorization: Bearer <MCP_AUTH_TOKEN>
```
You can also enable the built-in OAuth 2.1 Authorization Server for ChatGPT connections. The following values are environment-file examples, not shell commands:
You can also enable the built-in OAuth 2.1 Authorization Server for compatible OAuth-capable MCP clients, including ChatGPT. The following values are environment-file examples, not shell commands:
```dotenv
MCP_OAUTH_ENABLED=true
@@ -175,7 +191,7 @@ When enabled, the server provides:
- `resource` audience validation
- Access tokens, replay-detecting refresh token rotation, and grant-level token revocation
OAuth uses a single `mcp:tools` scope. Enter the `MCP_OAUTH_APPROVAL_KEY` value on the approval page shown when authorizing a ChatGPT connection. For OAuth-only deployments, it is recommended to leave `MCP_AUTH_TOKEN` empty so there is no permanent static Bearer bypass path. For backward compatibility, `MCP_AUTH_TOKEN` is used as the approval key when no dedicated approval key is configured, but keeping the two values separate is safer. Treat both values like root credentials. Registered clients, client secrets, and token hashes are stored in `MCP_OAUTH_STATE_FILE` with mode `600`.
OAuth uses a single `mcp:tools` scope. Enter the `MCP_OAUTH_APPROVAL_KEY` value on the approval page shown when authorizing an OAuth client connection. For OAuth-only deployments, it is recommended to leave `MCP_AUTH_TOKEN` empty so there is no permanent static Bearer bypass path. For backward compatibility, `MCP_AUTH_TOKEN` is used as the approval key when no dedicated approval key is configured, but keeping the two values separate is safer. Treat both values like root credentials. Registered clients, client secrets, and token hashes are stored in `MCP_OAUTH_STATE_FILE` with mode `600`.
OAuth-related HTTP routes:
@@ -299,7 +315,7 @@ Example healthy response:
- `activeMcpSessions` is always `0` in stateless mode. This does not mean the connection is broken.
- `activeMcpRequests` is the number of MCP HTTP requests being processed at the time of the health request.
- `managedProcesses` includes both currently running processes and recently completed processes retained temporarily for output retrieval. Check the `status` field from `list_processes` to determine whether a process is still running. Completed records are removed after `MCP_PROCESS_RETENTION_MS`.
- `managedProcesses` includes both currently running processes and recently completed processes retained temporarily for output retrieval. Check the `running` field from `list_processes` to determine whether a process is still running. Completed records are removed after `MCP_PROCESS_RETENTION_MS`.
- Every MCP response includes an `X-Request-Id` for tracing. Service log entries with `event="mcp_request"` record the RPC method, tool name, HTTP status, outcome, and duration without logging authentication tokens or tool arguments.
To inspect recent MCP request logs only:
@@ -356,7 +372,7 @@ This verification executes real commands on the target server and creates, modif
| `MCP_TRUST_PROXY_HOPS` | `0` | Number of trusted reverse-proxy hops; keep `0` when directly exposed |
| `MCP_AUTH_TOKEN` | none | Optional static Bearer token |
| `MCP_ALLOW_NO_AUTH` | `false` | Allow startup without authentication |
| `MCP_OAUTH_ENABLED` | `false` | Enable built-in OAuth 2.1/DCR for ChatGPT |
| `MCP_OAUTH_ENABLED` | `false` | Enable the built-in OAuth 2.1/DCR authorization server |
| `MCP_OAUTH_APPROVAL_KEY` | `MCP_AUTH_TOKEN` | Dedicated key for the OAuth connection approval page |
| `MCP_OAUTH_ISSUER` | `MCP_PUBLIC_URL` | OAuth issuer URL |
| `MCP_OAUTH_RESOURCE` | `<MCP_PUBLIC_URL><MCP_ENDPOINT>` | MCP resource audience |
+101 -74
View File
@@ -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
View File
@@ -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)),
-1
View File
@@ -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
View File
@@ -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
View File
@@ -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 {
+60
View File
@@ -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;
}
+41 -1
View File
@@ -38,6 +38,29 @@ const ALL_TOOLS = [
type ToolName = (typeof ALL_TOOLS)[number];
type ToolResult = Awaited<ReturnType<Client["callTool"]>>;
const EXPECTED_ANNOTATIONS = {
apply_patch: [false, true, false, false],
chmod_path: [false, true, true, false],
copy_path: [false, true, true, false],
download_file: [true, false, true, false],
exec_command: [false, true, false, true],
hash_file: [true, false, true, false],
list_directory: [true, false, true, false],
list_processes: [true, false, true, false],
make_directory: [false, false, true, false],
move_path: [false, true, true, false],
read_file: [true, false, true, false],
read_process: [true, false, true, false],
remove_path: [false, true, true, false],
replace_in_file: [false, true, false, false],
run_script: [false, true, false, true],
stat_path: [true, false, true, false],
terminate_process: [false, true, false, false],
upload_file: [false, true, true, false],
write_file: [false, true, false, false],
write_stdin: [false, true, false, true],
} as const satisfies Record<ToolName, readonly [boolean, boolean, boolean, boolean]>;
function structured(result: ToolResult): Record<string, unknown> {
return (result.structuredContent ?? {}) as Record<string, unknown>;
}
@@ -159,7 +182,14 @@ describe.sequential("all registered MCP tools", () => {
expect(listed.tools.map((tool) => tool.name).sort()).toEqual([...ALL_TOOLS]);
for (const tool of listed.tools) {
expect(tool.inputSchema.type).toBe("object");
expect(tool.annotations).toBeDefined();
const [readOnlyHint, destructiveHint, idempotentHint, openWorldHint] =
EXPECTED_ANNOTATIONS[tool.name as ToolName];
expect(tool.annotations, `${tool.name} annotations`).toEqual({
readOnlyHint,
destructiveHint,
idempotentHint,
openWorldHint,
});
}
});
@@ -758,6 +788,16 @@ describe.sequential("all registered MCP tools", () => {
path: "transfer/move-destination.txt",
cwd: testRoot,
})).toMatchObject({ content: "move-source" });
expect(await callError("move_path", {
sourcePath: "transfer/move-source.txt",
destinationPath: "transfer/move-destination.txt",
cwd: testRoot,
overwrite: true,
})).toMatch(/ENOENT|no such file/i);
expect(await callOk("read_file", {
path: "transfer/move-destination.txt",
cwd: testRoot,
})).toMatchObject({ content: "move-source" });
expect(await callOk("move_path", {
sourcePath: "transfer/move-destination.txt",
destinationPath: "transfer/move-destination.txt",
+24
View File
@@ -132,4 +132,28 @@ describe("ProcessManager", () => {
expect(first.output + second.output).toBe(expected);
expect(first.output + second.output).not.toContain("");
});
it("lists processes without pruning and expires completed sessions independently", async () => {
manager = new ProcessManager({
maxRetainedOutputBytes: 1024 * 1024,
processRetentionMs: 500,
maxProcesses: 16,
defaultMaxOutputBytes: 1024 * 1024,
});
const sessionId = manager.start({
executable: "/bin/bash",
args: ["-c", "true"],
commandForDisplay: "true",
cwd: process.cwd(),
});
await manager.waitForExit(sessionId, 2000);
expect(manager.list()).toEqual(
expect.arrayContaining([expect.objectContaining({ sessionId, running: false })]),
);
await expect(manager.read(sessionId)).resolves.toMatchObject({ running: false });
await new Promise((resolve) => setTimeout(resolve, 600));
await expect(manager.read(sessionId)).rejects.toThrow("Unknown process session");
});
});
+124
View File
@@ -0,0 +1,124 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { describe, expect, it } from "vitest";
import { loadConfig } from "../src/config.js";
import { createMcpServer, createServices } from "../src/mcp-server.js";
async function withClient<T>(
env: NodeJS.ProcessEnv,
operation: (client: Client) => Promise<T> | T,
): Promise<T> {
const config = loadConfig(env, "/tmp");
const server = createMcpServer(config, createServices(config));
const client = new Client({ name: "tool-metadata-test", version: "1.0.0" });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
await server.connect(serverTransport);
await client.connect(clientTransport);
try {
return await operation(client);
} finally {
await client.close();
await server.close();
}
}
async function listTools(env: NodeJS.ProcessEnv) {
return withClient(env, async (client) => (await client.listTools()).tools);
}
describe("tool authentication metadata", () => {
it("advertises the OAuth scope on every tool when OAuth is enabled", async () => {
const tools = await listTools({
MCP_OAUTH_ENABLED: "true",
MCP_OAUTH_APPROVAL_KEY: "approval-key",
MCP_PUBLIC_URL: "https://mcp.example.com",
MCP_OAUTH_ISSUER: "https://mcp.example.com",
MCP_OAUTH_RESOURCE: "https://mcp.example.com/mcp",
});
expect(tools).toHaveLength(20);
for (const tool of tools) {
expect(tool._meta, tool.name).toEqual({
securitySchemes: [{ type: "oauth2", scopes: ["mcp:tools"] }],
});
}
});
it("does not infer noauth from the internal authentication bypass", async () => {
const tools = await listTools({ MCP_ALLOW_NO_AUTH: "true" });
expect(tools).toHaveLength(20);
for (const tool of tools) {
expect(tool._meta, tool.name).toBeUndefined();
}
});
it("does not mislabel static bearer authentication as noauth or OAuth", async () => {
const tools = await listTools({ MCP_AUTH_TOKEN: "static-secret" });
expect(tools).toHaveLength(20);
for (const tool of tools) {
expect(tool._meta, tool.name).toBeUndefined();
}
});
});
describe("client-facing metadata accuracy", () => {
it("does not mislabel the MCP service origin as an implementation website", async () => {
const serverInfo = await withClient(
{
MCP_AUTH_TOKEN: "static-secret",
MCP_PUBLIC_URL: "https://mcp.example.com",
},
(client) => client.getServerVersion(),
);
expect(serverInfo).toMatchObject({ name: "cokacremote", version: "0.1.0" });
expect(serverInfo?.websiteUrl).not.toBe("https://mcp.example.com");
});
it("describes every tool and every input field", async () => {
const tools = await listTools({ MCP_AUTH_TOKEN: "static-secret" });
expect(tools).toHaveLength(20);
for (const tool of tools) {
expect(tool.title?.trim().length, tool.name).toBeGreaterThan(0);
expect(tool.description?.trim().length, tool.name).toBeGreaterThan(0);
for (const [fieldName, schema] of Object.entries(
tool.inputSchema.properties ?? {},
)) {
const description = (schema as { description?: unknown }).description;
expect(
typeof description === "string" ? description.trim().length : 0,
`${tool.name}.${fieldName}`,
).toBeGreaterThan(0);
}
}
});
it("describes process timing, session, polling, and escalation semantics exactly", async () => {
const tools = await listTools({ MCP_AUTH_TOKEN: "static-secret" });
const byName = new Map(tools.map((tool) => [tool.name, tool]));
const execCommand = byName.get("exec_command")!;
const runScript = byName.get("run_script")!;
const writeStdin = byName.get("write_stdin")!;
const terminateProcess = byName.get("terminate_process")!;
expect(execCommand.description).toContain("always returns a process session ID");
expect(writeStdin.description).toContain("greater than afterSeq");
expect(terminateProcess.description).toContain("SIGINT and SIGTERM");
for (const tool of [execCommand, runScript]) {
const properties = tool.inputSchema.properties as Record<
string,
{ description?: string }
>;
expect(properties.timeoutMs?.description).toContain("sending SIGTERM");
expect(properties.timeoutMs?.description).toContain("sent SIGKILL");
expect(properties.timeoutMs?.description).not.toContain("Maximum runtime");
expect(properties.yieldTimeMs?.description).toContain("wait for");
expect(properties.yieldTimeMs?.description).toContain("to exit");
}
});
});