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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 27 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,68 +27,77 @@ npm run build

## Configuration

The server is configured via environment variables:
All options are configured via environment variables.

| Variable | Required | Default | Description |
|---|---|---|---|
| `TONIC_TEXTUAL_API_KEY` | Yes | — | Your Tonic Textual API key |
| `TONIC_TEXTUAL_BASE_URL` | No | `https://textual.tonic.ai` | Base URL of your Textual instance |
| `PORT` | No | `3000` | HTTP port for the MCP server |
| `TONIC_TEXTUAL_TRANSPORT` | No | `http` | Transport mode: `http` or `stdio` |
| `PORT` | No | `3000` | HTTP port (ignored in stdio mode) |
| `TONIC_TEXTUAL_MAX_CONCURRENT_REQUESTS` | No | `50` | Max concurrent requests to the Textual API |

Run `textual-mcp --help` to print all options with their descriptions and defaults.

## Running the server

`TONIC_TEXTUAL_API_KEY` is **required**. For a self-hosted Textual instance, also set `TONIC_TEXTUAL_BASE_URL`.

### Global install
The server supports two transport modes controlled by `TONIC_TEXTUAL_TRANSPORT`:

- [**`http`**](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) (default) — starts an HTTP server; clients connect via URL
- [**`stdio`**](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#stdio) — reads/writes MCP JSON-RPC on stdin/stdout; the client manages the process lifecycle

### HTTP mode

```bash
# Tonic Textual cloud
# Global install
TONIC_TEXTUAL_API_KEY=your-key textual-mcp

# Self-hosted instance
TONIC_TEXTUAL_API_KEY=your-key TONIC_TEXTUAL_BASE_URL=https://your-instance.example.com textual-mcp
```

### From source
The server starts on `http://localhost:3000/mcp` by default. A health check endpoint is available at `http://localhost:3000/health`.

```bash
# Tonic Textual cloud
TONIC_TEXTUAL_API_KEY=your-key npm start
### stdio mode

# Self-hosted instance
TONIC_TEXTUAL_API_KEY=your-key TONIC_TEXTUAL_BASE_URL=https://your-instance.example.com npm start
```bash
TONIC_TEXTUAL_API_KEY=your-key TONIC_TEXTUAL_TRANSPORT=stdio textual-mcp
```

The server starts on `http://localhost:3000/mcp` by default. A health check endpoint is available at `http://localhost:3000/health`.
In stdio mode logs are written to `stderr` (and the log file) so they don't interfere with the MCP wire protocol on `stdout`.

## Adding to Claude

> **Note:** You must start the MCP server before adding it to your Claude client. See [Running the server](#running-the-server) above.
Both Claude Code and Claude Desktop use **stdio transport**, where the client starts and manages the server process automatically.

### Claude Code

With the server running, register it as an HTTP transport:

```bash
claude mcp add --transport http textual-mcp http://localhost:3000/mcp
claude mcp add --transport stdio textual-mcp -- env TONIC_TEXTUAL_API_KEY=your-key TONIC_TEXTUAL_TRANSPORT=stdio textual-mcp
```

### Claude Desktop

With the server running, add the following to your Claude Desktop config file (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):
Add the following to your Claude Desktop config file (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS):

```json
{
"mcpServers": {
"textual-mcp": {
"type": "http",
"url": "http://localhost:3000/mcp"
"command": "textual-mcp",
"env": {
"TONIC_TEXTUAL_API_KEY": "your-key",
"TONIC_TEXTUAL_TRANSPORT": "stdio"
}
}
}
}
```

For a self-hosted Textual instance, add `"TONIC_TEXTUAL_BASE_URL": "https://your-instance.example.com"` to `env`.

## Available tools

### Text redaction
Expand Down
14 changes: 11 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.12.1",
"mime-types": "^2.1.35"
"mime-types": "^2.1.35",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/mime-types": "^2.1.4",
Expand Down
135 changes: 125 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { InMemoryTaskStore } from "@modelcontextprotocol/sdk/experimental/tasks";
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
Expand All @@ -21,18 +22,103 @@ import {
} from "./textual-client.js";
import { Logger, withLogging } from "./logger.js";

const BASE_URL = process.env.TONIC_TEXTUAL_BASE_URL || "https://textual.tonic.ai";
const API_KEY = process.env.TONIC_TEXTUAL_API_KEY;
// ---------------------------------------------------------------------------
// Environment variable configuration schema
// ---------------------------------------------------------------------------

const envSchema = z.object({
TONIC_TEXTUAL_API_KEY: z
.string()
.min(1)
.describe("(required) Tonic Textual API key"),
TONIC_TEXTUAL_BASE_URL: z
.string()
.url()
.default("https://textual.tonic.ai")
.describe("(optional) Tonic Textual base URL [default: https://textual.tonic.ai]"),
TONIC_TEXTUAL_TRANSPORT: z
.enum(["http", "stdio"])
.default("http")
.describe("(optional) Transport mode: http or stdio [default: http]"),
PORT: z
.string()
.regex(/^\d+$/, "Must be a positive integer")
.default("3000")
.transform(Number)
.describe("(optional) HTTP port to listen on, ignored in stdio mode [default: 3000]"),
TONIC_TEXTUAL_MAX_CONCURRENT_REQUESTS: z
.string()
.regex(/^\d+$/, "Must be a positive integer")
.default("50")
.transform(Number)
.describe("(optional) Maximum concurrent requests to the Textual API [default: 50]"),
TONIC_TEXTUAL_POLL_TIMEOUT_SECONDS: z
.string()
.regex(/^\d+$/, "Must be a positive integer")
.default("900")
.transform(Number)
.describe("(optional) Timeout in seconds when polling for file processing completion [default: 900]"),
TONIC_TEXTUAL_LOG_DIR: z
.string()
.default("./logs")
.describe("(optional) Directory to write log files to [default: ./logs]"),
});

if (!API_KEY) {
console.error("TONIC_TEXTUAL_API_KEY environment variable is required");
process.exit(1);
/** Print a help table derived from the schema's field descriptions and exit. */
function printEnvHelp(errors?: z.ZodError): never {
const shape = envSchema.shape;
const lines: string[] = [
"Tonic Textual MCP server — PII detection and de-identification for AI assistants",
"",
"Configuration is provided via environment variables:",
"",
];

const nameWidth = Math.max(...Object.keys(shape).map((k) => k.length));
for (const [key, field] of Object.entries(shape)) {
const desc = field.description ?? "";
lines.push(` ${key.padEnd(nameWidth)} ${desc}`);
}

if (errors) {
lines.push("");
lines.push("Errors:");
for (const issue of errors.issues) {
const envVar = issue.path.join(".");
lines.push(` ${envVar}: ${issue.message}`);
}
}

// Always write to stderr so it is visible even in stdio transport mode
process.stderr.write(lines.join("\n") + "\n");
process.exit(errors ? 1 : 0);
}

// Parse and validate env vars; exit with help on failure
if (process.argv.includes("--help") || process.argv.includes("-h")) {
printEnvHelp();
}

const logger = new Logger();
const MAX_CONCURRENT = parseInt(process.env.TONIC_TEXTUAL_MAX_CONCURRENT_REQUESTS || "50", 10);
const envResult = envSchema.safeParse(process.env);
if (!envResult.success) {
printEnvHelp(envResult.error);
}
const env = envResult.data;

// ---------------------------------------------------------------------------
// Apply config
// ---------------------------------------------------------------------------

const BASE_URL: string = env.TONIC_TEXTUAL_BASE_URL;
const API_KEY = env.TONIC_TEXTUAL_API_KEY;

const logger = new Logger(
env.TONIC_TEXTUAL_LOG_DIR,
env.TONIC_TEXTUAL_TRANSPORT === "stdio" ? process.stderr : process.stdout
);
const MAX_CONCURRENT = env.TONIC_TEXTUAL_MAX_CONCURRENT_REQUESTS;
const POLL_INTERVAL_MS = 5000;
const POLL_TIMEOUT_S = parseInt(process.env.TONIC_TEXTUAL_POLL_TIMEOUT_SECONDS || "900", 10);
const POLL_TIMEOUT_S = env.TONIC_TEXTUAL_POLL_TIMEOUT_SECONDS;
const client = new TextualClient(BASE_URL, API_KEY, logger, MAX_CONCURRENT);

// --- Shared schemas ---
Expand Down Expand Up @@ -783,8 +869,25 @@ Recommended workflow: use scan_directory to preview, test redact_text/redact_jso
// ============================================================
// Start the server
// ============================================================
async function main() {
const port = parseInt(process.env.PORT || "3000", 10);

async function runStdio() {
const server = new McpServer(
{ name: "tonic-textual", version: "1.0.0" },
{ taskStore: new InMemoryTaskStore() }
);
registerTools(server);
const transport = new StdioServerTransport();
await server.connect(transport);
logger.info("Tonic Textual MCP server running on stdio");

process.stdin.on("close", () => {
logger.info("stdin closed, shutting down");
server.close().finally(() => process.exit(0));
});
}

async function runHttp() {
const port = env.PORT;

// Each session gets its own McpServer + Transport pair so that
// in-flight request state, abort controllers, and response handlers
Expand Down Expand Up @@ -875,6 +978,18 @@ async function main() {
});
}

async function main() {
switch (env.TONIC_TEXTUAL_TRANSPORT) {
case "stdio":
await runStdio();
break;
case "http":
default:
await runHttp();
break;
}
}

process.on("uncaughtException", (err) => {
logger.error("uncaught_exception", { error: String(err), stack: err.stack });
});
Expand Down
4 changes: 2 additions & 2 deletions src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ export class Logger {
private currentFilePath: string = "";
private currentSize: number = 0;

constructor(logDir?: string) {
constructor(logDir?: string, consoleStream?: NodeJS.WriteStream) {
this.logDir = logDir || process.env.TONIC_TEXTUAL_LOG_DIR || "./logs";
this.consoleStream = process.stdout;
this.consoleStream = consoleStream ?? process.stdout;
fs.mkdirSync(this.logDir, { recursive: true });
this.openStream();
}
Expand Down