Skip to content
Open
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
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ bundle (`--ca` or the OS trust store).
| Variable | Purpose |
|---|---|
| `MCP_LOGGER` | Set to `true` to enable diagnostic logging on stdout (off by default — required off for MCP stdio clients). |
| `AEM_USER` / `AEM_PASS` | Basic-auth credentials for AEM. Precedence is **flag > env > default** (`-u`/`-p` win when given; `admin` is the fallback). Preferred for stdio clients — supply them via the client's `env` config block (not `args[]`, which shows in `ps aux`) and omit `-u`/`-p`. |
| `MCP_USERNAME` / `MCP_PASSWORD` | Optional HTTP Basic auth gate on `POST /mcp` (only active when both are set). |
| `MCP_BIND` | Default bind interface (overrides built-in `127.0.0.1`). CLI `--bind` takes precedence. |
| `MCP_ALLOWED_ORIGINS` | Comma-separated extra `Origin` values allowed on `/mcp`. Inspector ports 6274/6277 on `localhost`/`127.0.0.1` are always allowed. |
Expand Down Expand Up @@ -217,6 +218,62 @@ Sample for AI-based code editors or custom clients:
}
```

### Stdio mode (Claude Desktop, Cursor, VS Code)

Local clients that spawn the server as a subprocess speak JSON-RPC over
stdin/stdout. Run with `--stdio` (alias `-e`) — **no HTTP port is bound** in
this mode. Pass credentials through the client's **`env` block, never
`args[]`** (args are visible in `ps aux`; env is readable only by the process
owner), and omit `-u`/`-p` so the env credentials are used (precedence is
flag > env > default).

**Claude Desktop** (`claude_desktop_config.json`) and **Cursor**
(`~/.cursor/mcp.json`):

```json
{
"mcpServers": {
"AEM": {
"command": "npx",
"args": ["-y", "@netcentric/aem-mcp-server", "--stdio", "-H=https://author.example.com"],
"env": {
"AEM_USER": "your-user",
"AEM_PASS": "your-pass"
}
}
}
}
```

**VS Code** (`.vscode/mcp.json`) uses a `servers` key and an explicit
`"type": "stdio"`:

```json
{
"servers": {
"AEM": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@netcentric/aem-mcp-server", "--stdio", "-H=https://author.example.com"],
"env": {
"AEM_USER": "your-user",
"AEM_PASS": "your-pass"
}
}
}
}
```

> **mTLS / OAuth in stdio mode** — swap the `env` block for
> `AEM_CERT_PATH`/`AEM_KEY_PATH` (+ `AEM_KEY_PASSPHRASE` if encrypted) or pass
> `-i`/`-s` for OAuth, exactly as in HTTP mode.
>
> **Trust boundary** — stdio has no CORS/Origin/session-auth layer: any local
> process that can write the subprocess's stdin gets the full tool surface
> (incl. page delete and replication). This is an accepted trade-off of the
> OS-process-isolation model — the client owns the child process. Do not
> expose the stdin of this process to untrusted input.

## Usage

```
Expand Down
Binary file added docs/screenshots/stdio-connected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/aem/aem.auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export class BasicAuthStrategy implements AuthStrategy {
* (é/ü/ñ/etc.) round-trip correctly. Passwords with code points > 0xFF still
* can't be expressed in Basic auth and are out of scope.
*/
readonly encodedToken: string;
private readonly encodedToken: string;

constructor(username: string, password: string) {
if (!username || !password) {
Expand Down
4 changes: 2 additions & 2 deletions src/aem/aem.connector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2546,7 +2546,7 @@ export class AEMConnector {
*/
async getAvailableTemplates(parentPath: string): Promise<object> {
return safeExecute<object>(async () => {
console.log('getAvailableTemplates for parentPath:', parentPath);
LOGGER.log('getAvailableTemplates for parentPath:', parentPath);
// Try to determine site configuration from parent path
let confPath = '/conf';
const pathParts = parentPath.split('/');
Expand Down Expand Up @@ -3224,7 +3224,7 @@ export class AEMConnector {
clearTemplateCache(): void {
this.templateCache.clear();
this.templateCacheExpiry.clear();
console.log('🗑️ Template cache cleared');
LOGGER.log('🗑️ Template cache cleared');
}

/**
Expand Down
27 changes: 27 additions & 0 deletions src/cli.schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { z } from 'zod';

// ----------------------------------------------------------------------------
// CliParamsSchema — shape validation for the non-cert CLI inputs (feat: stdio, B2)
// ----------------------------------------------------------------------------
//
// Cert-auth inputs are validated separately by CertParamsSchema (feat #4); this
// schema covers the transport-selection + host shape that both modes share:
// - stdio: optional boolean (mode selector).
// - host: must be a syntactically valid URL.
//
// Embedded credentials in `--host` (`user:pass@`) are rejected earlier in
// cli.ts via `hasUrlCredentials` — this schema only enforces URL shape: strings
// without any scheme (e.g. `author.example.com`) are caught early; wrong-scheme
// or unreachable hosts surface as a network error at fetch time.
//
// Callers MUST use `safeParse(...)`, never `.parse()`, and must NOT echo the
// raw input back: zod's `.url()` message is value-free ("Invalid url"), but the
// cli.ts error path additionally runs the message through `sanitizeErrorMessage`
// so a credential in any future value-bearing issue never reaches stderr.

export const CliParamsSchema = z.object({
stdio: z.boolean().optional(),
host: z.string().url({ message: 'must be a valid URL (e.g. https://author.example.com)' }).optional(),
});

export type ValidatedCliParams = z.infer<typeof CliParamsSchema>;
60 changes: 53 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,23 @@

import yargs from 'yargs';
import { hideBin } from 'yargs/helpers';
import { startServer } from './index.js';
import { startServer, startStdioServer } from './index.js';
import { CliParams } from './types';
import { hasUrlCredentials } from './utils/sanitize.js';
import { hasUrlCredentials, sanitizeErrorMessage } from './utils/sanitize.js';
import { CertParamsSchema } from './aem/aem.auth.schemas.js';
import { CliParamsSchema } from './cli.schemas.js';

type CliArgs = CliParams & {
help?: boolean;
};

const argv: CliArgs = yargs(hideBin(process.argv)).options({
host: { type: 'string', default: 'http://localhost:4502', alias: 'H' },
user: { type: 'string', default: 'admin', alias: 'u' },
pass: { type: 'string', default: 'admin', alias: 'p' },
// No yargs `default` for user/pass: we need to tell "flag explicitly passed"
// apart from "flag absent" so env vars can fill the gap. The 'admin' fallback
// is applied at resolution (flag > env > default). See AEM_USER/AEM_PASS below.
user: { type: 'string', alias: 'u', describe: 'AEM Basic-auth user. Flag wins over AEM_USER env. Default: admin.' },
pass: { type: 'string', alias: 'p', describe: 'AEM Basic-auth password. Flag wins over AEM_PASS env. Default: admin.' },
id: { type: 'string', default: '', alias: 'i', describe: 'clientId' },
secret: { type: 'string', default: '', alias: 's', describe: 'clientSecret' },
cert: {
Expand All @@ -36,6 +40,12 @@ const argv: CliArgs = yargs(hideBin(process.argv)).options({
default: Number(process.env.AEM_CERT_WATCH_INTERVAL_MIN) || 0,
describe: 'periodically check the cert file mtime every N minutes; on change, reload PEMs and rebuild the undici.Agent (rotation without restart). 0 disables (default). SIGHUP still works regardless. Env: AEM_CERT_WATCH_INTERVAL_MIN.',
},
stdio: {
type: 'boolean',
default: false,
alias: 'e',
describe: 'run as a stdio MCP subprocess (JSON-RPC over stdin/stdout) instead of the HTTP server. Mutually exclusive with the HTTP mode — no port is bound. For Claude Desktop / Cursor / VS Code.',
},
mcpPort: { type: 'number', default: 8502, alias: 'm' },
bind: {
type: 'string',
Expand All @@ -62,15 +72,37 @@ if (argv.help) {
process.exit(0); // prevent startServer from running
}

const { host, user, pass, mcpPort, id, secret, bind } = argv;
const { host, mcpPort, id, secret, bind, stdio } = argv;
const allowOrigin = argv.allowOrigin ?? [];
const shutdownDrainSeconds = argv.shutdownDrainSeconds ?? 60;

// Basic-auth credentials follow standard precedence: flag > env > default
// (feat: stdio, B4). Matches the cert-path rule and POSIX/12-factor convention
// — an explicit flag always wins, env fills the gap, 'admin' is the local-dev
// fallback. Subprocess MCP clients (Claude Desktop / Cursor / VS Code) should
// supply secrets via their `env` block (AEM_USER/AEM_PASS): more private than
// `args[]`, which is visible in `ps aux`.
const user = argv.user ?? process.env.AEM_USER ?? 'admin';
const pass = argv.pass ?? process.env.AEM_PASS ?? 'admin';

if (host && hasUrlCredentials(host)) {
console.error('Error: --host (-H) must not contain embedded credentials. Pass them via -u/-p (Basic) or -i/-s (OAuth) instead.');
process.exit(1);
}

// URL-shape + transport-flag validation (feat: stdio, B2). Runs after the
// credentials-in-host guard so an embedded-cred host gets the specific message
// above rather than a generic URL error. Sanitize the issue message before it
// reaches stderr — defense-in-depth against a future value-bearing zod issue.
const cliValidation = CliParamsSchema.safeParse({ stdio, host });
if (!cliValidation.success) {
const issue = cliValidation.error.issues[0];
const pathSeg = issue?.path?.[0];
const label = typeof pathSeg === 'string' && pathSeg.length > 0 ? `--${pathSeg}` : 'cli';
console.error(`Error: ${label}: ${sanitizeErrorMessage(issue?.message ?? 'validation failed')}`);
process.exit(1);
}

// Cert-auth params. Flags take precedence over env vars; `??` only falls
// through on null/undefined so an empty `--cert ""` reaches the schema and
// gets rejected by `.min(1)` (instead of being silently coerced to "no cert
Expand Down Expand Up @@ -98,7 +130,7 @@ const { cert, key, ca, passphrase } = certValidation.data;

const certWatchIntervalMin = argv.certWatchIntervalMin ?? 0;

startServer({
const params = {
host,
user,
pass,
Expand All @@ -113,4 +145,18 @@ startServer({
allowOrigin,
bind,
shutdownDrainSeconds,
});
stdio,
};

// Strict XOR: stdio mode and the HTTP server are mutually exclusive. Running
// both would leave an unauthenticated HTTP endpoint bound on mcpPort alongside
// the stdio subprocess — double the attack surface. In stdio mode no port is
// ever bound.
if (stdio) {
startStdioServer(params).catch((err) => {
process.stderr.write(`[stdio] fatal: ${err?.message ?? err}\n`);
process.exit(1);
});
} else {
startServer(params);
}
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import { startServer } from './server/app.server.js';
import { startServer, startStdioServer } from './server/app.server.js';

export { startServer };
export { startServer, startStdioServer };
24 changes: 21 additions & 3 deletions src/mcp/mcp.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,19 @@ import { tools } from './mcp.tools.js';
import { MCPRequestHandler } from './mcp.aem-handler.js';
import { CliParams } from '../types.js';
import { LOGGER } from '../utils/logger.js';
import { sanitizeForWire } from '../utils/sanitize.js';

export const createMCPServer = (cliParams: CliParams) => {
/**
* Optional transport-level hooks. Kept transport-agnostic so stdio-specific
* behaviour stays out of this file (leakage rule): startStdioServer() supplies
* an `onToolCall` that writes the stderr audit line (feat: stdio, B3); the HTTP
* path passes nothing.
*/
export type MCPServerHooks = {
onToolCall?: (name: string, args: Record<string, unknown> | undefined) => void;
};

export const createMCPServer = (cliParams: CliParams, hooks: MCPServerHooks = {}) => {
const mcpHandler = new MCPRequestHandler(cliParams);

const serverInfo = {
Expand Down Expand Up @@ -42,6 +53,9 @@ export const createMCPServer = (cliParams: CliParams) => {

server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
// Audit hook fires for every CallTool attempt (incl. ones rejected below
// for missing args) so the trail records intent, not just successes.
hooks.onToolCall?.(name, args);
LOGGER.log('3. Received CallToolRequestSchema', request.params);
if (!args) {
return {
Expand Down Expand Up @@ -76,7 +90,7 @@ export const createMCPServer = (cliParams: CliParams) => {

return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
} catch (error: any) {
LOGGER.error('ERROR CallToolRequestSchema', error.message);
LOGGER.error('ERROR CallToolRequestSchema', error instanceof Error ? error.message : String(error));

// Check if it's an OAuth error
if (error.code === 'OAUTH_REQUIRED' && error.authUrl) {
Expand All @@ -97,8 +111,12 @@ export const createMCPServer = (cliParams: CliParams) => {
};
}

// sanitizeForWire: collapse CR/LF so a multi-line AEM error renders as a
// single readable line. The transport JSON.stringify's this response, so
// newlines are already escaped for the wire — this is readability +
// defense-in-depth, not required for JSON-RPC framing.
return {
content: [{ type: 'text', text: `Error: ${error.message}` }],
content: [{ type: 'text', text: sanitizeForWire(`Error: ${error instanceof Error ? error.message : String(error)}`) }],
isError: true,
};
}
Expand Down
Loading
Loading