Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"typescript/no-unused-vars": [
"error",
{ "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }
]
],
"no-shadow": "warn"
},
"overrides": [
{
Expand Down
46 changes: 25 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,20 @@ Works with Claude Code, Claude Desktop, Cursor, Windsurf, Copilot, and any MCP-c

## Getting started

### Setup wizard

The interactive wizard handles config, auth tokens, keychain storage, and PII mode in one step:

```bash
# Project-level — writes .mcp.json in the current directory (shared with your team)
npx @hayodev/crystallize-mcp --setup

# Global — registers via `claude mcp add` (Claude Code) or writes Claude Desktop config
npx @hayodev/crystallize-mcp --setup --global
```

### Manual config

Standard MCP config (works in any client):

```json
Expand All @@ -30,7 +44,7 @@ Standard MCP config (works in any client):
Add `CRYSTALLIZE_ACCESS_TOKEN_ID` and `CRYSTALLIZE_ACCESS_TOKEN_SECRET` to the `env` block for PIM tools (shapes, orders, customers). See [Authentication](#authentication).

<details>
<summary>Claude Code</summary>
<summary>Claude Code (CLI)</summary>

```bash
claude mcp add crystallize \
Expand All @@ -42,24 +56,12 @@ claude mcp add crystallize \

Use `--scope project` to write to `.mcp.json` (shared with your team) or `--scope user` for personal use across all projects.

Or run the guided setup wizard, which can optionally store tokens in the OS keychain instead of plain text:

```bash
npx @hayodev/crystallize-mcp --setup
```

</details>

<details>
<summary>Claude Desktop</summary>

Run the guided wizard — it writes directly to `claude_desktop_config.json` and can store tokens in the macOS Keychain so they never appear in the config file:

```bash
npx @hayodev/crystallize-mcp --setup --global
```

Or add the standard config manually to `~/Library/Application Support/Claude/claude_desktop_config.json`.
Add the standard config to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows).

</details>

Expand Down Expand Up @@ -135,13 +137,15 @@ Or copy the standard config JSON above before opening the command — Raycast wi
</details>

<details>
<summary>From source</summary>
<summary>From source (maintainers)</summary>

The `--local` flag is for developing crystallize-mcp itself. It writes `.mcp.json` pointing to the local build output — **run this from the repo root only**:

```bash
git clone https://github.com/HayoDev/crystallize-mcp.git
cd crystallize-mcp
npm install && npm run build
npx . --setup --local # writes .mcp.json pointing to local build
npx . --setup --local # writes .mcp.json pointing to ./build/
```

Or point your MCP client directly at the built entry point:
Expand Down Expand Up @@ -311,14 +315,14 @@ Easy to pipe into log aggregators (Datadog, CloudWatch, Splunk) — but ensure y

### Keychain storage (optional)

The setup wizard (`npx @hayodev/crystallize-mcp --setup`) can store tokens in the OS keychain (macOS Keychain, Windows Credential Manager, or libsecret on Linux) so they never appear as plain text in config files. This is particularly useful for:
The setup wizard (`npx @hayodev/crystallize-mcp --setup`) can store tokens in the OS keychain (macOS Keychain, Windows Credential Manager, or libsecret on Linux) so they never appear as plain text in config files. The MCP server resolves credentials from the keychain automatically at startup — no extra configuration needed.

- **Claude Desktop users** — config is written to a JSON file with no CLI equivalent for secret management
- **Shared `.mcp.json`** — when your project config is committed to git, keychain storage keeps tokens out of the repository
This is useful when:

When tokens are in the keychain, the config only needs `CRYSTALLIZE_TENANT_IDENTIFIER` — credentials are resolved automatically at startup.
- **Your `.mcp.json` is committed to git** — keychain keeps tokens out of the repository
- **You prefer not to have secrets in plain text config files** — the config only needs `CRYSTALLIZE_TENANT_IDENTIFIER`

Note: CLI-based MCP clients (`claude mcp add`, Cursor, Copilot, etc.) store env vars as plain text in their config files and do not integrate with the OS keychain directly. If plain text env vars in a local config file are acceptable for your setup, the wizard's keychain option is not needed.
When using `--setup --global` with Claude Code, this applies only if keychain storage is available and you opt into it: in that case, the wizard runs `claude mcp add` with only the non-secret env vars, and tokens are read from the keychain at runtime so they do not appear in Claude Code config. If keychain storage is unavailable or you choose not to use it, the wizard passes token env vars to `claude mcp add`, and Claude Code may store them in plain text.

### Access mode

Expand Down
130 changes: 103 additions & 27 deletions src/bin/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@
*/

import { createInterface } from 'node:readline';
import { writeFileSync, readFileSync, existsSync } from 'node:fs';
import { resolve as resolvePath } from 'node:path';
import { writeFileSync, readFileSync, existsSync, mkdirSync } from 'node:fs';
import { resolve as resolvePath, dirname } from 'node:path';
import { homedir } from 'node:os';
import { execFileSync } from 'node:child_process';
import { isKeychainAvailable, writeCredentials } from '../credentials.js';

const rl = createInterface({ input: process.stdin, output: process.stderr });
Expand Down Expand Up @@ -86,6 +87,25 @@ async function main() {
// Access mode
const accessMode = await ask('Access mode: read / write / admin', 'read');

// PII mode — relevant when accessing customer/order data
console.error(
'\nPII mode controls how customer data (emails, phones, addresses) is returned:',
);
console.error(' full — all data returned as-is (default)');
console.error(' masked — emails and phones partially masked');
console.error(' none — contact/PII fields stripped entirely\n');
const piiMode = await ask('PII mode: full / masked / none', 'full');

// Dry-run — only relevant for write/admin modes
let dryRun = false;
if (accessMode === 'write' || accessMode === 'admin') {
console.error(
'\nDry-run mode previews mutations without executing them — useful for testing.',
);
const dryRunAnswer = await ask('Enable dry-run mode? y/n', 'n');
dryRun = dryRunAnswer.toLowerCase() === 'y';
}

// Keychain offer — only if tokens were entered and keychain is reachable
const hasSecrets = tokenId || tokenSecret || staticToken;
let useKeychain = false;
Expand Down Expand Up @@ -139,6 +159,12 @@ async function main() {
if (accessMode !== 'read') {
env.CRYSTALLIZE_ACCESS_MODE = accessMode;
}
if (piiMode !== 'full') {
env.CRYSTALLIZE_PII_MODE = piiMode;
}
if (dryRun) {
env.CRYSTALLIZE_DRY_RUN = 'true';
}

// Build MCP config entry
const mcpEntry = isLocal
Expand All @@ -154,36 +180,76 @@ async function main() {
};

if (isGlobal) {
// Claude Desktop config
const configPath =
process.platform === 'darwin'
? resolvePath(
homedir(),
'Library/Application Support/Claude/claude_desktop_config.json',
)
: resolvePath(
homedir(),
'AppData/Roaming/Claude/claude_desktop_config.json',
);
// Try Claude Code CLI first, then fall back to Claude Desktop config file
const hasClaudeCli = (() => {
try {
execFileSync('claude', ['--version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
})();

if (hasClaudeCli) {
// Use `claude mcp add` for Claude Code
const args = ['mcp', 'add', '--transport', 'stdio'];
for (const [key, val] of Object.entries(env)) {
args.push('--env', `${key}=${val}`);
}
args.push('crystallize', '--');
if (isLocal) {
args.push(
'node',
resolvePath(process.cwd(), 'build/src/bin/crystallize-mcp.js'),
);
} else {
args.push('npx', '-y', '@hayodev/crystallize-mcp@latest');
}

let config: Record<string, unknown> = {};
if (existsSync(configPath)) {
try {
config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<
string,
unknown
>;
execFileSync('claude', args, { stdio: 'inherit' });
console.error('\n✅ Added via Claude Code CLI: claude mcp add');
} catch {
// start fresh
console.error(
'\n⚠️ `claude mcp add` failed. You can add it manually:',
);
console.error(
` claude mcp add crystallize -- npx -y @hayodev/crystallize-mcp@latest`,
);
}
}
} else {
// Fall back to Claude Desktop config file
const configPath =
process.platform === 'darwin'
? resolvePath(
homedir(),
'Library/Application Support/Claude/claude_desktop_config.json',
)
: resolvePath(
homedir(),
'AppData/Roaming/Claude/claude_desktop_config.json',
);

const servers = (config.mcpServers ?? {}) as Record<string, unknown>;
servers.crystallize = mcpEntry;
config.mcpServers = servers;
let config: Record<string, unknown> = {};
if (existsSync(configPath)) {
try {
config = JSON.parse(readFileSync(configPath, 'utf-8')) as Record<
string,
unknown
>;
} catch {
// start fresh
}
}

writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
console.error(`\n✅ Added to Claude Desktop config: ${configPath}`);
const servers = (config.mcpServers ?? {}) as Record<string, unknown>;
servers.crystallize = mcpEntry;
config.mcpServers = servers;

mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
console.error(`\n✅ Added to Claude Desktop config: ${configPath}`);
}
} else {
// Local .mcp.json for Claude Code
const configPath = resolvePath(process.cwd(), '.mcp.json');
Expand All @@ -203,6 +269,7 @@ async function main() {
servers.crystallize = mcpEntry;
config.mcpServers = servers;

mkdirSync(dirname(configPath), { recursive: true });
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n');
console.error(`\n✅ Created ${configPath}`);
}
Expand All @@ -216,7 +283,16 @@ async function main() {
} else {
console.error('Auth: none (public catalogue only)');
}
console.error(`Mode: ${accessMode}\n`);
console.error(`Mode: ${accessMode}`);
if (piiMode !== 'full') {
console.error(`PII: ${piiMode}`);
}
if (dryRun) {
console.error(
'Dry-run: enabled (mutations will be previewed, not executed)',
);
}
console.error('');

rl.close();
}
Expand Down
4 changes: 2 additions & 2 deletions tests/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,10 +331,10 @@ describe('schema introspection with mock API', () => {
});

// Generate a schema large enough to exceed the 50k auto-summary threshold
const largeTypes = Array.from({ length: 200 }, (_, i) => ({
const largeTypes = Array.from({ length: 200 }, (_a, i) => ({
kind: 'OBJECT',
name: `GeneratedType${i}`,
fields: Array.from({ length: 20 }, (_, j) => ({
fields: Array.from({ length: 20 }, (_b, j) => ({
name: `field${j}WithALongNameToInflateSize`,
type: { kind: 'SCALAR', name: 'String', ofType: null },
args: [],
Expand Down
Loading