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
3 changes: 0 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,4 @@ COPY . .
# Build the application
RUN npm run build

# Add environment variable for Gyazo access token
ENV GYAZO_ACCESS_TOKEN=dummy_token

CMD ["node", "build/index.js"]
89 changes: 71 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,27 +55,43 @@ npm install -g @notainc/gyazo-mcp-server
### Prerequisites

- Create a Gyazo account if you don't have one: https://gyazo.com
- Get your Gyazo API access token from: https://gyazo.com/api
- Click "Register applications" button
- Click "New Application" button
- Fill in the form with your app name and description
- Name and Callback URL are required
- You can use `http://localhost` for the Callback URL
- Click "Submit" button
- Click application name to view details
- Scroll down to "Your Access Token"
- Click "Generate" button
- Copy "Your access token" value
- Set the `GYAZO_ACCESS_TOKEN` environment variable with your token

### Claude Desktop Integration

To use with Claude Desktop, add the server config:
- Choose one of the following authentication methods:

#### Option A: Personal Access Token

1. Go to https://gyazo.com/api
2. Click "Register applications" > "New Application"
3. Fill in the form (Name and Callback URL are required; you can use `http://localhost` for the Callback URL)
4. Click "Submit", then click your application name to view details
5. Scroll down to "Your Access Token" and click "Generate"
6. Copy the access token value
7. Set the `GYAZO_ACCESS_TOKEN` environment variable with your token

#### Option B: OAuth Client Flow

OAuth authentication allows access to images shared within your Gyazo Teams organization, not just your own uploads.

1. Go to https://gyazo.com/oauth/applications and create a new application
2. Set the Callback URL to `http://localhost:18439/callback`
3. Note your `Client ID` and `Client Secret`
4. Run the authentication flow:

```bash
GYAZO_CLIENT_ID=your-client-id \
GYAZO_CLIENT_SECRET=your-client-secret \
npx @notainc/gyazo-mcp-server --auth
```

This opens your browser for authorization and saves the token to `~/.gyazo-mcp/token.json`. You only need to do this once.

### MCP Client Integration

To use with MCP clients (Claude Desktop, Claude Code, etc.), add the server config:

On MacOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
On Windows: `%APPDATA%/Claude/claude_desktop_config.json`

#### Using NPM package (recommended)
#### Using NPM package with access token

```json
{
Expand All @@ -91,7 +107,22 @@ On Windows: `%APPDATA%/Claude/claude_desktop_config.json`
}
```

#### Using Docker (optional)
#### Using NPM package with OAuth token

After running `--auth`, the stored token is used automatically:

```json
{
"mcpServers": {
"gyazo-mcp-server": {
"command": "npx",
"args": ["@notainc/gyazo-mcp-server"]
}
}
}
```

#### Using Docker with access token

```json
{
Expand All @@ -114,6 +145,28 @@ On Windows: `%APPDATA%/Claude/claude_desktop_config.json`
}
```

#### Using Docker with OAuth token

Mount the token file into the container:

```json
{
"mcpServers": {
"gyazo-mcp-server": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-v",
"~/.gyazo-mcp:/root/.gyazo-mcp",
"gyazo-mcp-server"
]
}
}
}
```

## Development

Install dependencies:
Expand Down
2 changes: 1 addition & 1 deletion src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export async function fetchImageAsBase64(
}

return {
data: `data:${contentType};base64,${base64Data}`,
data: base64Data,
mimeType: contentType,
};
}
Expand Down
163 changes: 163 additions & 0 deletions src/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/**
* OAuth authentication flow for Gyazo API
* Handles authorization code grant flow with local callback server
*/
import * as http from "node:http";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import * as crypto from "node:crypto";

const GYAZO_AUTHORIZE_URL = "https://gyazo.com/oauth/authorize";
const GYAZO_TOKEN_URL = "https://gyazo.com/oauth/token";
const TOKEN_DIR = path.join(os.homedir(), ".gyazo-mcp");
const TOKEN_FILE = path.join(TOKEN_DIR, "token.json");
const CALLBACK_PORT = 18439;
const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/callback`;

interface StoredToken {
access_token: string;
}

/**
* Load stored access token from file
*/
export function loadStoredToken(): string | null {
try {
if (fs.existsSync(TOKEN_FILE)) {
const data = JSON.parse(fs.readFileSync(TOKEN_FILE, "utf-8"));
return data.access_token || null;
}
} catch {
// Ignore read errors
}
return null;
}

/**
* Save access token to file
*/
function saveToken(token: StoredToken): void {
if (!fs.existsSync(TOKEN_DIR)) {
fs.mkdirSync(TOKEN_DIR, { mode: 0o700, recursive: true });
}
fs.writeFileSync(TOKEN_FILE, JSON.stringify(token, null, 2), {
mode: 0o600,
});
}

/**
* Exchange authorization code for access token
*/
async function exchangeCodeForToken(
code: string,
clientId: string,
clientSecret: string,
): Promise<string> {
const response = await fetch(GYAZO_TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: clientId,
client_secret: clientSecret,
redirect_uri: REDIRECT_URI,
code,
grant_type: "authorization_code",
}),
});

if (!response.ok) {
throw new Error(`Token exchange failed: ${response.status}`);
}

const data = await response.json();
return data.access_token;
}

/**
* Start local HTTP server to receive OAuth callback
* Returns a promise that resolves with the authorization code
*/
function waitForCallback(
state: string,
): Promise<{ code: string; server: http.Server }> {
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
const url = new URL(req.url || "", `http://localhost:${CALLBACK_PORT}`);
if (url.pathname !== "/callback") {
res.writeHead(404);
res.end();
return;
}

const code = url.searchParams.get("code");
const returnedState = url.searchParams.get("state");

if (!code || returnedState !== state) {
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
res.end("<h1>認証に失敗しました</h1><p>ウィンドウを閉じてください。</p>");
reject(new Error("Invalid callback: missing code or state mismatch"));
return;
}

res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(
"<h1>認証が完了しました</h1><p>このウィンドウを閉じてください。</p>",
);
resolve({ code, server });
});

server.listen(CALLBACK_PORT, () => {
// Server is ready
});

server.on("error", reject);
});
}

/**
* Open URL in the default browser
*/
async function openBrowser(url: string): Promise<void> {
const { exec } = await import("node:child_process");
const command =
process.platform === "darwin"
? "open"
: process.platform === "win32"
? "start"
: "xdg-open";
exec(`${command} '${url}'`);
}

/**
* Run the OAuth authorization flow
* Opens browser for user authorization, receives callback, exchanges code for token
*/
export async function runOAuthFlow(
clientId: string,
clientSecret: string,
): Promise<string> {
const state = crypto.randomBytes(16).toString("hex");

const authUrl = new URL(GYAZO_AUTHORIZE_URL);
authUrl.searchParams.set("client_id", clientId);
authUrl.searchParams.set("redirect_uri", REDIRECT_URI);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("state", state);

const callbackPromise = waitForCallback(state);

console.error(
`Opening browser for Gyazo authorization...\n${authUrl.toString()}`,
);
await openBrowser(authUrl.toString());

const { code, server } = await callbackPromise;
server.close();

const accessToken = await exchangeCodeForToken(code, clientId, clientSecret);
saveToken({ access_token: accessToken });

console.error("Gyazo OAuth authentication successful. Token saved.");
return accessToken;
}
34 changes: 29 additions & 5 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Configuration related processing
*/
import dotenv from "dotenv";
import { loadStoredToken } from "./auth.js";

// Load environment variables from .env file
dotenv.config();
Expand All @@ -20,14 +21,37 @@ export const SERVER_CONFIG = {
version: "0.1.0",
};

// Cached access token set by OAuth flow
let cachedAccessToken: string | null = null;

/**
* Set access token (called after OAuth flow completes)
*/
export function setAccessToken(token: string): void {
cachedAccessToken = token;
}

/**
* Get Gyazo API access token
* Validates that the token exists and throws an error if not
* Priority: env var > cached OAuth token > stored OAuth token
*/
export function getAccessToken(): string {
const token = process.env.GYAZO_ACCESS_TOKEN;
if (!token) {
throw new Error("GYAZO_ACCESS_TOKEN environment variable is required");
const envToken = process.env.GYAZO_ACCESS_TOKEN;
if (envToken) {
return envToken;
}
return token;

if (cachedAccessToken) {
return cachedAccessToken;
}

const storedToken = loadStoredToken();
if (storedToken) {
cachedAccessToken = storedToken;
return storedToken;
}

throw new Error(
"No access token available. Set GYAZO_ACCESS_TOKEN or configure GYAZO_CLIENT_ID and GYAZO_CLIENT_SECRET for OAuth.",
);
}
2 changes: 1 addition & 1 deletion src/handlers/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const listResourcesHandler = {
resources: gyazoImages.map((gyazoImage) => ({
uri: `gyazo-mcp:///${gyazoImage.image_id}`,
mimeType: `image/${gyazoImage.type}`,
name: gyazoImage.metadata.title || gyazoImage.image_id,
name: gyazoImage.metadata?.title || gyazoImage.image_id,
})),
};
} catch (error) {
Expand Down
Loading