From c7a6918d920991fe199afa00649e1972bcb8bfd8 Mon Sep 17 00:00:00 2001 From: ishii-masaki Date: Wed, 11 Mar 2026 15:28:58 +0900 Subject: [PATCH 1/2] feat: add OAuth client flow with --auth flag Add support for Gyazo OAuth authorization code grant flow as an alternative to setting GYAZO_ACCESS_TOKEN directly. Users can run `gyazo-mcp-server --auth` with GYAZO_CLIENT_ID and GYAZO_CLIENT_SECRET to authenticate via browser and store the token at ~/.gyazo-mcp/token.json. Token priority: GYAZO_ACCESS_TOKEN env var > stored OAuth token. Remove dummy GYAZO_ACCESS_TOKEN from Dockerfile. Update README with OAuth setup instructions and Docker volume mount examples. Co-Authored-By: Claude Opus 4.6 --- Dockerfile | 3 - README.md | 89 +++++++++++++++++++++------ src/auth.ts | 163 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/config.ts | 34 +++++++++-- src/index.ts | 51 +++++++++++++++- 5 files changed, 313 insertions(+), 27 deletions(-) create mode 100644 src/auth.ts diff --git a/Dockerfile b/Dockerfile index f862d3a..41a8152 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index 54e1a7c..f49c414 100644 --- a/README.md +++ b/README.md @@ -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 { @@ -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 { @@ -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: diff --git a/src/auth.ts b/src/auth.ts new file mode 100644 index 0000000..93ce100 --- /dev/null +++ b/src/auth.ts @@ -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 { + 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("

認証に失敗しました

ウィンドウを閉じてください。

"); + reject(new Error("Invalid callback: missing code or state mismatch")); + return; + } + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end( + "

認証が完了しました

このウィンドウを閉じてください。

", + ); + 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 { + 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 { + 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; +} diff --git a/src/config.ts b/src/config.ts index e0d3f1f..e191e31 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,7 @@ * Configuration related processing */ import dotenv from "dotenv"; +import { loadStoredToken } from "./auth.js"; // Load environment variables from .env file dotenv.config(); @@ -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.", + ); } diff --git a/src/index.ts b/src/index.ts index 6d8ad15..161b8ca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,7 +10,8 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { SERVER_CONFIG } from "./config.js"; +import { SERVER_CONFIG, setAccessToken } from "./config.js"; +import { loadStoredToken, runOAuthFlow } from "./auth.js"; import { listResourcesHandler, readResourceHandler, @@ -48,11 +49,59 @@ server.setRequestHandler( server.setRequestHandler(listToolsHandler.schema, listToolsHandler.handler); server.setRequestHandler(callToolHandler.schema, callToolHandler.handler); +/** + * Ensure access token is available, running OAuth flow if needed + */ +async function ensureAccessToken(): Promise { + if (process.env.GYAZO_ACCESS_TOKEN) { + return; + } + + const storedToken = loadStoredToken(); + if (storedToken) { + setAccessToken(storedToken); + return; + } + + const clientId = process.env.GYAZO_CLIENT_ID; + const clientSecret = process.env.GYAZO_CLIENT_SECRET; + if (!clientId || !clientSecret) { + throw new Error( + "No access token available. Set GYAZO_ACCESS_TOKEN, or set GYAZO_CLIENT_ID and GYAZO_CLIENT_SECRET for OAuth.", + ); + } + + const token = await runOAuthFlow(clientId, clientSecret); + setAccessToken(token); +} + +/** + * Run OAuth authentication flow and exit + */ +async function authMode(): Promise { + const clientId = process.env.GYAZO_CLIENT_ID; + const clientSecret = process.env.GYAZO_CLIENT_SECRET; + if (!clientId || !clientSecret) { + console.error( + "GYAZO_CLIENT_ID and GYAZO_CLIENT_SECRET environment variables are required for OAuth.", + ); + process.exit(1); + } + + await runOAuthFlow(clientId, clientSecret); +} + /** * Start the server using stdio transport * Communicate via standard input/output streams */ async function main() { + if (process.argv.includes("--auth")) { + await authMode(); + return; + } + + await ensureAccessToken(); const transport = new StdioServerTransport(); await server.connect(transport); } From d7b18e9c501a3f1a1fce7193a19bc1ee1372a4ff Mon Sep 17 00:00:00 2001 From: ishii-masaki Date: Wed, 11 Mar 2026 15:33:08 +0900 Subject: [PATCH 2/2] fix: return raw base64 and make metadata fields optional Include fixes from #129 and #130: - Remove data URI prefix from fetchImageAsBase64 return value - Make metadata and its fields optional to handle missing API responses Co-Authored-By: Claude Opus 4.6 --- src/api.ts | 2 +- src/handlers/resources.ts | 2 +- src/types.ts | 10 +++++----- src/utils.ts | 16 ++++++++-------- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/api.ts b/src/api.ts index d2a7df9..524adb4 100644 --- a/src/api.ts +++ b/src/api.ts @@ -84,7 +84,7 @@ export async function fetchImageAsBase64( } return { - data: `data:${contentType};base64,${base64Data}`, + data: base64Data, mimeType: contentType, }; } diff --git a/src/handlers/resources.ts b/src/handlers/resources.ts index 13ca326..60bb92b 100644 --- a/src/handlers/resources.ts +++ b/src/handlers/resources.ts @@ -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) { diff --git a/src/types.ts b/src/types.ts index 5597c18..63aad01 100644 --- a/src/types.ts +++ b/src/types.ts @@ -12,11 +12,11 @@ export type GyazoImage = { url: string; type: string; created_at: string; - metadata: { - app: string; - title: string; - url: string; - desc: string; + metadata?: { + app?: string | null; + title?: string | null; + url?: string | null; + desc?: string | null; }; ocr?: { locale: string; diff --git a/src/utils.ts b/src/utils.ts index caed230..879378f 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -9,17 +9,17 @@ import { GyazoImage } from "./types.js"; */ export function getImageMetadataMarkdown(gyazoImage: GyazoImage): string { let imageMetadataMarkdown = ""; - if (gyazoImage.metadata.title) { - imageMetadataMarkdown += `### Title:\n${gyazoImage.metadata.title}\n\n`; + if (gyazoImage.metadata?.title) { + imageMetadataMarkdown += `### Title:\n${gyazoImage.metadata?.title}\n\n`; } - if (gyazoImage.metadata.desc) { - imageMetadataMarkdown += `### Description:\n${gyazoImage.metadata.desc}\n\n`; + if (gyazoImage.metadata?.desc) { + imageMetadataMarkdown += `### Description:\n${gyazoImage.metadata?.desc}\n\n`; } - if (gyazoImage.metadata.app) { - imageMetadataMarkdown += `### App:\n${gyazoImage.metadata.app}\n\n`; + if (gyazoImage.metadata?.app) { + imageMetadataMarkdown += `### App:\n${gyazoImage.metadata?.app}\n\n`; } - if (gyazoImage.metadata.url) { - imageMetadataMarkdown += `### URL:\n${gyazoImage.metadata.url}\n\n`; + if (gyazoImage.metadata?.url) { + imageMetadataMarkdown += `### URL:\n${gyazoImage.metadata?.url}\n\n`; } if (gyazoImage.ocr?.description) { imageMetadataMarkdown += `### OCR:\n${gyazoImage.ocr.description}\n\n`;