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
547 changes: 114 additions & 433 deletions examples/basic_usage.ipynb

Large diffs are not rendered by default.

92 changes: 92 additions & 0 deletions examples/claude_code_via_mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# chemscii Claude Code Integration via MCP

This example demonstrates how to integrate chemscii with [Claude Code](https://github.com/anthropics/claude-code) using the Model Context Protocol (MCP).

## How It Works

Claude Code supports MCP servers that provide tools Claude can use directly. The chemscii MCP server exposes a `render_molecule` tool that Claude can call to render chemical structures without needing CLI instructions.

Unlike the CLAUDE.md approach, MCP integration:
- Works globally (not directory-scoped)
- Provides a structured tool interface
- Gives Claude direct access to rendering parameters

## Setup

1. Install chemscii:
```bash
pip install chemscii
```

2. Add the MCP server to your Claude Code settings. Edit `~/.claude.json`:
```json
"mcpServers": {
"chemscii": {
"command": "chemscii",
"type": "stdio",
"args": [
"--mcp"
]
}
}
```

3. Restart Claude Code to load the MCP server. Check with `claude mcp list`

## Example Usage

Once configured, Claude Code has access to the `render_molecule` tool. Try these prompts:

### Basic Rendering
> "Show me caffeine"

> "What does aspirin look like?"

> "Render the molecule that gives bananas their smell"

### Renderer Options
> "Show me cholesterol using the unicode renderer"

> "Render dopamine with ascii art"

### Chemistry Questions
> "What's the structure of the active ingredient in coffee?"

> "Show me the difference between glucose and fructose"

### Using SMILES Notation
> "Render this SMILES: c1ccc2c(c1)cc1ccccc1n2"

## Tool Parameters

The `render_molecule` tool accepts these parameters:

| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `molecule` | string | required | SMILES string, molecule name, ChEMBL ID, or SDF content |
| `renderer` | string | "magic" | Renderer type: "ascii", "unicode", or "magic" |
| `width` | int | 60 | Canvas width for ascii/unicode renderers |
| `height` | int | 30 | Canvas height for ascii/unicode renderers |
| `columns` | int | 80 | Output width for magic renderer |
| `escape_codes` | bool | false | Include escape codes for color rendering |

## How This Differs from Other Integrations

| Approach | Setup Required | Works With | Scope |
|----------|---------------|------------|-------|
| CLAUDE.md | None | Claude Code | Directory-scoped |
| **MCP Server (this)** | JSON config editing | Claude Code, Claude Desktop | Global |
| API Script | API key + environment | Any Python environment | Programmatic |

## Advantages of MCP

- **Global availability**: Works in any directory once configured
- **Structured interface**: Claude receives typed parameters and documentation
- **Works with Claude Desktop**: Can also be used with Claude Desktop app
- **Explicit tool calls**: You can see when Claude uses the tool in the conversation

## Limitations

- **Requires configuration**: Must edit settings.json to enable
- **Server process**: Runs as a subprocess managed by Claude Code
- **Single instance**: One server configuration per settings file
8 changes: 8 additions & 0 deletions examples/claude_code_via_mcp/settings.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"mcpServers": {
"chemscii": {
"command": "chemscii",
"args": ["--mcp"]
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# chemscii Claude Code Integration
# chemscii Claude Code Integration Via CLAUDE.md

When users ask about chemical structures, molecules, drugs, or compounds, render them using the `chemscii` CLI.
If your response to a question includes SMILES, molecules, or ChEMBL IDs render them using the `chemscii` CLI.
Expand All @@ -8,14 +8,14 @@ If your response to a question includes SMILES, molecules, or ChEMBL IDs render
Use the magic renderer (default) with 100 columns:

```bash
chemscii "<molecule>" --columns 80
chemscii "<molecule>"
```

Examples:
- `chemscii "caffeine" --columns 80` - render by common name
- `chemscii "aspirin" --columns 80` - render by drug name
- `chemscii "CCO" --columns 80` - render by SMILES notation
- `chemscii "CHEMBL25" --columns 80` - render by ChEMBL ID
- `chemscii "caffeine"` - render by common name
- `chemscii "aspirin"` - render by drug name
- `chemscii "CCO"` - render by SMILES notation
- `chemscii "CHEMBL25"` - render by ChEMBL ID

## Larger Renderings

Expand Down
File renamed without changes.
883 changes: 881 additions & 2 deletions poetry.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "chemscii"
version = "0.1.1"
version = "0.1.2"
description = "Render chemical structures as ASCII/Unicode art"
authors = ["Benjamin J. Shields"]
readme = "README.md"
Expand All @@ -13,6 +13,7 @@ pillow = "^10.0"
rich = "^13.0"
ascii-magic = "^2.7.2"
typer = "^0.15"
mcp = "^1.25"

[tool.poetry.group.dev.dependencies]
pytest = "^8.0"
Expand Down
55 changes: 45 additions & 10 deletions src/chemscii/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,15 @@ def parse_input(input_type: InputType, value: str) -> str | None:

@app.command()
def main(
molecule: str = typer.Argument(
...,
molecule: str | None = typer.Argument(
None,
help="SMILES string, molecule name, ChEMBL ID, or file path.",
),
mcp_mode: bool = typer.Option(
False,
"--mcp",
help="Start MCP server instead of rendering a molecule.",
),
ascii_mode: bool = typer.Option(
False,
"--ascii",
Expand All @@ -125,17 +130,11 @@ def main(
help="Use image-to-ASCII magic renderer (default).",
),
columns: int = typer.Option(
50,
80,
"--columns",
"-c",
help="Output width for magic renderer.",
),
escape_codes: bool = typer.Option(
False,
"--codes",
"-e",
help="Include escape codes for color rendering after copying text.",
),
width: int = typer.Option(
60,
"--width",
Expand All @@ -153,7 +152,43 @@ def main(

Automatically detects input type: SMILES strings, molecule names,
ChEMBL IDs, or structure files (.sdf, .mol, .smi).

Use --mcp to start an MCP server for AI assistant integration.
"""
# Handle MCP mode
if mcp_mode:
from chemscii.mcp import run_server

# Determine default renderer from CLI flags
renderer: Literal["ascii", "unicode", "magic"]
if ascii_mode:
renderer = "ascii"
elif unicode_mode:
renderer = "unicode"
else:
renderer = "magic"

run_server(
renderer=renderer,
width=width,
height=height,
columns=columns,
)
return

# Require molecule argument when not in MCP mode
if molecule is None:
error_console.print(
Panel(
"[red]Missing required argument: molecule[/red]\n\n"
"Usage: chemscii [MOLECULE]\n\n"
"Use --mcp to start the MCP server instead.",
title="Error",
border_style="red",
)
)
raise typer.Exit(1)

from rdkit.rdBase import BlockLogs

# Detect input type
Expand Down Expand Up @@ -212,7 +247,7 @@ def main(
unicode_renderer.render_molecule(mol)
else:
# Default to magic
magic_renderer = AsciiMagicRenderer(columns=columns, codes=escape_codes)
magic_renderer = AsciiMagicRenderer(columns=columns)
magic_renderer.render_molecule(mol)


Expand Down
144 changes: 144 additions & 0 deletions src/chemscii/mcp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""MCP server for chemscii molecule rendering."""

from __future__ import annotations

import io
import sys
from typing import Literal

from mcp.server.fastmcp import FastMCP

from chemscii.cli import detect_input_type, parse_input
from chemscii.parsers.molecule import parse_smiles
from chemscii.renderers.ascii import AsciiRenderer
from chemscii.renderers.magic import AsciiMagicRenderer
from chemscii.renderers.unicode import UnicodeRenderer

RendererType = Literal["ascii", "unicode", "magic"]

mcp = FastMCP("chemscii")


def _resolve_smiles(molecule: str) -> str | None:
"""Resolve molecule input to SMILES string.

Args:
molecule: SMILES string, molecule name, ChEMBL ID, or SDF content.

Returns:
SMILES string if resolution succeeds, None otherwise.
"""
from rdkit.rdBase import BlockLogs

# Check if it looks like SDF content (multi-line with atom block markers)
if "\n" in molecule and ("V2000" in molecule or "V3000" in molecule):
from rdkit import Chem

from chemscii.parsers.molecule import parse_sdf

mol = parse_sdf(molecule)
if mol is not None:
return str(Chem.MolToSmiles(mol))
return None

# Use existing detection logic for SMILES/name/ChEMBL
with BlockLogs():
input_type, normalized = detect_input_type(molecule)

return parse_input(input_type, normalized)


def _render(
smiles: str,
renderer: RendererType,
width: int,
height: int,
columns: int,
) -> str:
"""Render a molecule using the specified renderer.

Args:
smiles: SMILES string to render.
renderer: Renderer type to use.
width: Canvas width for ascii/unicode renderers.
height: Canvas height for ascii/unicode renderers.
columns: Output width for magic renderer.

Returns:
ASCII/Unicode art representation of the molecule.
"""
mol = parse_smiles(smiles)
if mol is None:
raise ValueError(f"Failed to parse SMILES: {smiles}")

# Capture stdout since renderers print to stdout
old_stdout = sys.stdout
sys.stdout = io.StringIO()

try:
r: AsciiRenderer | UnicodeRenderer | AsciiMagicRenderer
if renderer == "ascii":
r = AsciiRenderer(width=width, height=height)
elif renderer == "unicode":
r = UnicodeRenderer(width=width, height=height)
else:
r = AsciiMagicRenderer(columns=columns, codes=True)
result: str = r.render_molecule(mol)
# result: str = repr(sys.stdout.getvalue())

return result
finally:
sys.stdout.close()
sys.stdout = old_stdout


@mcp.tool() # type: ignore[misc]
def render_molecule(
molecule: str,
renderer: RendererType = "magic",
width: int = 60,
height: int = 30,
columns: int = 80,
) -> str:
"""Render a chemical structure as ASCII/Unicode art.

Args:
molecule: SMILES string, molecule name, ChEMBL ID, or SDF content.
renderer: Renderer type: "ascii", "unicode", or "magic" (default).
width: Canvas width for ascii/unicode renderers.
height: Canvas height for ascii/unicode renderers.
columns: Output width for magic renderer.

Returns:
ASCII/Unicode art representation of the molecule.
"""
smiles = _resolve_smiles(molecule)
if smiles is None:
return f"Error: Could not parse molecule input: {molecule}"

try:
return _render(smiles, renderer, width, height, columns)
except Exception as e:
return f"Error rendering molecule: {e}"


def run_server(
renderer: RendererType = "magic",
width: int = 60,
height: int = 30,
columns: int = 80,
) -> None:
"""Run the MCP server with optional default settings.

Args:
renderer: Default renderer type.
width: Default canvas width for ascii/unicode renderers.
height: Default canvas height for ascii/unicode renderers.
columns: Default output width for magic renderer.
escape_codes: Default for including escape codes.
"""
# Store defaults that could be used by the tool
# For now, the tool uses its own defaults but this allows future extension
_ = (renderer, width, height, columns)

mcp.run()
6 changes: 3 additions & 3 deletions src/chemscii/renderers/magic.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,10 @@ def render_molecule(self, mol: Mol) -> str:
img = self._mol_to_image(mol)
art = AsciiArt.from_pillow_image(img)
if self.codes:
txt: str = art.to_ascii(self.columns)
print(repr(txt))
txt: str = art.to_terminal(self.columns)
else:
txt = art.to_terminal(self.columns)
txt = art.to_ascii(self.columns)
print(repr(txt))
return txt

def _mol_to_image(
Expand Down
Loading
Loading