From 83ab377f81ee1a407aad87933cdbeea09e98be80 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 8 Mar 2026 17:27:10 -0400 Subject: [PATCH 1/4] feat: add first-class codex plugin directory --- README.md | 3 +- codex/.codex-plugin/plugin.json | 5 ++ codex/.mcp.json | 9 +++ codex/README.md | 81 +++++++++++++++++++++ codex/glance.test.ts | 121 ++++++++++++++++++++++++++++++++ codex/servers/glance-mcp.js | 38 ++++++++++ tsconfig.json | 1 + vitest.config.ts | 2 +- 8 files changed, 258 insertions(+), 2 deletions(-) create mode 100644 codex/.codex-plugin/plugin.json create mode 100644 codex/.mcp.json create mode 100644 codex/README.md create mode 100644 codex/glance.test.ts create mode 100644 codex/servers/glance-mcp.js diff --git a/README.md b/README.md index 4dbf12c..ffc0827 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Integration](https://github.com/modem-dev/glance-agent-plugins/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/modem-dev/glance-agent-plugins/actions/workflows/test.yml) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE) -[![Supported Agents](https://img.shields.io/badge/agents-pi%20%7C%20OpenCode%20%7C%20Claude%20Code-blue)](#available-plugins) +[![Supported Agents](https://img.shields.io/badge/agents-pi%20%7C%20OpenCode%20%7C%20Claude%20Code%20%7C%20Codex-blue)](#available-plugins) Agent integrations for [glance.sh](https://glance.sh) — temporary image sharing for coding agents. @@ -15,6 +15,7 @@ Paste a screenshot in your browser, your agent gets the URL instantly. | [pi](https://github.com/mariozechner/pi) | [`pi/`](pi/) | `@modemdev/glance-pi` | `pi install npm:@modemdev/glance-pi` | | [OpenCode](https://github.com/anomalyco/opencode) | [`opencode/`](opencode/) | `@modemdev/glance-opencode` | Add `"@modemdev/glance-opencode"` to `opencode.json` `plugin` list | | [Claude Code](https://github.com/anthropics/claude-code) | [`claude/`](claude/) | `@modemdev/glance-claude` | `/plugin marketplace add modem-dev/glance-agent-plugins` then `/plugin install glance-claude@glance-agent-plugins` | +| [Codex](https://developers.openai.com/codex) | [`codex/`](codex/) | n/a | `codex mcp add glance -- node /absolute/path/to/codex/servers/glance-mcp.js` | ## How it works diff --git a/codex/.codex-plugin/plugin.json b/codex/.codex-plugin/plugin.json new file mode 100644 index 0000000..c65f434 --- /dev/null +++ b/codex/.codex-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "glance-codex", + "description": "glance.sh MCP tools for Codex", + "mcpServers": "./.mcp.json" +} diff --git a/codex/.mcp.json b/codex/.mcp.json new file mode 100644 index 0000000..eb778cd --- /dev/null +++ b/codex/.mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "glance": { + "command": "node", + "args": ["servers/glance-mcp.js"], + "cwd": "." + } + } +} diff --git a/codex/README.md b/codex/README.md new file mode 100644 index 0000000..7779845 --- /dev/null +++ b/codex/README.md @@ -0,0 +1,81 @@ +# glance.sh plugin for Codex + +[Codex CLI](https://developers.openai.com/codex) integration that adds glance.sh screenshot tools via MCP. + +## What it does + +Adds two MCP tools: + +- **`glance`** — creates/reuses a live session and returns a URL like `https://glance.sh/s/` +- **`glance_wait`** — waits for the next pasted image and returns `Screenshot: https://glance.sh/.` + +The server keeps a background SSE listener alive, reconnects automatically, and refreshes sessions before they expire. + +## Install + +From this repository: + +```bash +codex mcp add glance -- node "$(pwd)/codex/servers/glance-mcp.js" +``` + +Or with an absolute path from anywhere: + +```bash +codex mcp add glance -- node /absolute/path/to/glance-agent-plugins/codex/servers/glance-mcp.js +``` + +## Verify + +1. Confirm the MCP server is configured: + +```bash +codex mcp list +codex mcp get glance --json +``` + +2. Ask Codex to call `glance`. +3. Open the returned `https://glance.sh/s/` URL and paste an image. +4. Ask Codex to call `glance_wait`. +5. Confirm Codex receives `Screenshot: `. + +## Update / remove + +Update to the latest plugin code: + +```bash +codex mcp remove glance +codex mcp add glance -- node /absolute/path/to/glance-agent-plugins/codex/servers/glance-mcp.js +``` + +Remove: + +```bash +codex mcp remove glance +``` + +## How it works + +```text +Codex calls glance + └─▶ MCP server POST /api/session + └─▶ returns session URL + +Codex calls glance_wait + └─▶ waits for SSE image event + +User pastes image at /s/ + └─▶ glance.sh emits image event + └─▶ tool returns Screenshot: +``` + +## Requirements + +- Codex CLI with MCP support +- Node.js runtime available to launch the stdio MCP server + +## Configuration + +Optional environment variable: + +- `GLANCE_BASE_URL` (default: `https://glance.sh`) diff --git a/codex/glance.test.ts b/codex/glance.test.ts new file mode 100644 index 0000000..76b30f7 --- /dev/null +++ b/codex/glance.test.ts @@ -0,0 +1,121 @@ +import { readFileSync } from "node:fs" +import { fileURLToPath } from "node:url" + +import { afterEach, describe, expect, it, vi } from "vitest" + +import { createCodexMcpServer, createGlanceRuntime } from "./servers/glance-mcp.js" + +type ToolResult = { + content: Array<{ type: string; text: string }> +} + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + headers: { "content-type": "application/json" }, + status, + }) +} + +function pendingSseResponse(signal?: AbortSignal): Promise { + return new Promise((_resolve, reject) => { + signal?.addEventListener( + "abort", + () => { + reject(new DOMException("Aborted", "AbortError")) + }, + { once: true }, + ) + }) +} + +describe("codex glance plugin", () => { + afterEach(() => { + vi.restoreAllMocks() + }) + + it("ships codex plugin manifest and MCP config", () => { + const pluginJsonPath = fileURLToPath(new URL("./.codex-plugin/plugin.json", import.meta.url)) + const mcpJsonPath = fileURLToPath(new URL("./.mcp.json", import.meta.url)) + + const manifest = JSON.parse(readFileSync(pluginJsonPath, "utf8")) as Record + const mcpConfig = JSON.parse(readFileSync(mcpJsonPath, "utf8")) as { + mcpServers?: Record> + } + + expect(manifest.name).toBe("glance-codex") + expect(manifest.mcpServers).toBe("./.mcp.json") + + expect(mcpConfig.mcpServers?.glance?.command).toBe("node") + expect(mcpConfig.mcpServers?.glance?.args).toEqual(["servers/glance-mcp.js"]) + expect(mcpConfig.mcpServers?.glance?.cwd).toBe(".") + }) + + it("returns a session URL from glance", async () => { + const fetchMock = vi.fn((input: string | URL, init?: RequestInit) => { + const url = String(input) + + if (url === "https://glance.sh/api/session") { + return Promise.resolve(jsonResponse({ id: "sess-1", url: "/s/sess-1" })) + } + + if (url === "https://glance.sh/api/session/sess-1/events") { + return pendingSseResponse(init?.signal as AbortSignal | undefined) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) + + const runtime = createGlanceRuntime({ fetchImpl: fetchMock, quietLogs: true }) + + const result = (await runtime.executeTool("glance")) as ToolResult + + expect(result.content[0].text).toContain("Session ready") + expect(result.content[0].text).toContain("https://glance.sh/s/sess-1") + + runtime.stopBackground() + }) + + it("creates an MCP server that routes JSON-RPC tool calls", async () => { + const runtime = { + getTools: vi.fn(() => [ + { + name: "glance", + description: "glance tool", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + }, + ]), + executeTool: vi.fn(async () => ({ + content: [{ type: "text", text: "Session ready" }], + })), + stopBackground: vi.fn(), + } + + const sent: Array> = [] + + const server = createCodexMcpServer({ + runtime, + serverOptions: { + sendMessage: (message: Record) => { + sent.push(message) + }, + quietLogs: true, + }, + }) + + await server.handleMessage({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name: "glance", arguments: {} }, + }) + + expect(runtime.executeTool).toHaveBeenCalledWith("glance", {}, expect.any(AbortSignal)) + expect(sent).toContainEqual({ + jsonrpc: "2.0", + id: 1, + result: { + content: [{ type: "text", text: "Session ready" }], + }, + }) + }) +}) diff --git a/codex/servers/glance-mcp.js b/codex/servers/glance-mcp.js new file mode 100644 index 0000000..9a7b140 --- /dev/null +++ b/codex/servers/glance-mcp.js @@ -0,0 +1,38 @@ +import { pathToFileURL } from "node:url" + +import { + createGlanceRuntime, + createMcpServer, +} from "../../claude/servers/glance-mcp.js" + +export { createGlanceRuntime, createMcpServer } + +export function createCodexMcpServer(options = {}) { + const runtime = options.runtime ?? createGlanceRuntime(options.runtimeOptions) + return createMcpServer({ + runtime, + ...options.serverOptions, + }) +} + +function isMainModule() { + if (!process.argv[1]) { + return false + } + + return import.meta.url === pathToFileURL(process.argv[1]).href +} + +if (isMainModule()) { + const server = createCodexMcpServer() + + const shutdown = () => { + server.stop() + process.exit(0) + } + + process.on("SIGINT", shutdown) + process.on("SIGTERM", shutdown) + + server.start() +} diff --git a/tsconfig.json b/tsconfig.json index 1dbf763..1c3d4ef 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,6 +20,7 @@ "opencode/**/*.ts", "pi/**/*.ts", "claude/**/*.ts", + "codex/**/*.ts", "test/**/*.ts", "vitest.config.ts" ] diff --git a/vitest.config.ts b/vitest.config.ts index fc48671..00e2353 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -18,7 +18,7 @@ export default defineConfig({ test: { clearMocks: true, coverage: { - include: ["opencode/**/*.ts", "pi/**/*.ts", "claude/**/*.js"], + include: ["opencode/**/*.ts", "pi/**/*.ts", "claude/**/*.js", "codex/**/*.js"], provider: "v8", reporter: ["text", "lcov"], }, From bb220a9cfc0c2686c130c70367bb9f45cc7e1509 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 8 Mar 2026 17:37:48 -0400 Subject: [PATCH 2/4] fix: add codex mcp type declarations --- codex/servers/glance-mcp.d.ts | 37 +++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 codex/servers/glance-mcp.d.ts diff --git a/codex/servers/glance-mcp.d.ts b/codex/servers/glance-mcp.d.ts new file mode 100644 index 0000000..c9deb5f --- /dev/null +++ b/codex/servers/glance-mcp.d.ts @@ -0,0 +1,37 @@ +import type { + GlanceRuntime, + McpRuntime, + McpServer, +} from "../../claude/servers/glance-mcp.js" + +export { createGlanceRuntime, createMcpServer } from "../../claude/servers/glance-mcp.js" + +export interface CodexMcpServerOptions { + runtime?: McpRuntime + runtimeOptions?: Parameters< + typeof import("../../claude/servers/glance-mcp.js").createGlanceRuntime + >[0] + serverOptions?: Parameters< + typeof import("../../claude/servers/glance-mcp.js").createMcpServer + >[0] +} + +export function createGlanceRuntime(options?: { + baseUrl?: string + fetchImpl?: typeof fetch + log?: (message: string) => void + quietLogs?: boolean +}): GlanceRuntime + +export function createMcpServer(options?: { + runtime?: McpRuntime + stdin?: NodeJS.ReadableStream + stdout?: NodeJS.WritableStream + stderr?: NodeJS.WritableStream + sendMessage?: (message: Record) => void + exit?: (code: number) => void + log?: (message: string) => void + quietLogs?: boolean +}): McpServer + +export function createCodexMcpServer(options?: CodexMcpServerOptions): McpServer From 4c27599540ecf4ae5edc96bbaf0ae58423a63077 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 8 Mar 2026 17:38:36 -0400 Subject: [PATCH 3/4] fix: align codex fetch mock types with DOM fetch signature --- codex/glance.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex/glance.test.ts b/codex/glance.test.ts index 76b30f7..ac163be 100644 --- a/codex/glance.test.ts +++ b/codex/glance.test.ts @@ -51,7 +51,7 @@ describe("codex glance plugin", () => { }) it("returns a session URL from glance", async () => { - const fetchMock = vi.fn((input: string | URL, init?: RequestInit) => { + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { const url = String(input) if (url === "https://glance.sh/api/session") { From e78c285a3ef0ea7482b69a1d072d1782f82382e1 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 8 Mar 2026 19:40:27 -0400 Subject: [PATCH 4/4] feat: package codex plugin for npm release --- .github/workflows/release-codex.yml | 75 +++ README.md | 2 +- codex/README.md | 33 +- codex/glance.test.ts | 12 +- codex/package.json | 34 ++ codex/servers/glance-mcp.d.ts | 62 ++- codex/servers/glance-mcp.js | 730 +++++++++++++++++++++++++++- 7 files changed, 921 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/release-codex.yml create mode 100644 codex/package.json mode change 100644 => 100755 codex/servers/glance-mcp.js diff --git a/.github/workflows/release-codex.yml b/.github/workflows/release-codex.yml new file mode 100644 index 0000000..16ad222 --- /dev/null +++ b/.github/workflows/release-codex.yml @@ -0,0 +1,75 @@ +name: Release codex package + +on: + push: + tags: + - 'codex-v*' + workflow_dispatch: + inputs: + publish: + description: Publish to npm (unchecked = dry run only) + type: boolean + required: false + default: false + +permissions: + contents: read + id-token: write + +concurrency: + group: release-codex + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Read package metadata + id: pkg + run: | + name=$(node -p "require('./codex/package.json').name") + version=$(node -p "require('./codex/package.json').version") + echo "name=$name" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Validate release tag matches package version + if: github.event_name == 'push' + run: | + expected="codex-v${{ steps.pkg.outputs.version }}" + if [ "${GITHUB_REF_NAME}" != "$expected" ]; then + echo "::error title=Tag/version mismatch::Expected tag $expected for version ${{ steps.pkg.outputs.version }}, got ${GITHUB_REF_NAME}." + exit 1 + fi + + - name: Ensure npm version is not already published + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish) + run: | + if npm view "${{ steps.pkg.outputs.name }}@${{ steps.pkg.outputs.version }}" version >/dev/null 2>&1; then + echo "::error title=Version already published::${{ steps.pkg.outputs.name }}@${{ steps.pkg.outputs.version }} already exists on npm." + exit 1 + fi + + - name: Validate package contents + working-directory: codex + run: npm pack --dry-run + + - name: Publish to npm + if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.publish) + working-directory: codex + run: npm publish --access public --provenance + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Dry-run summary + if: github.event_name == 'workflow_dispatch' && !inputs.publish + run: | + echo "Dry run complete for ${{ steps.pkg.outputs.name }}@${{ steps.pkg.outputs.version }}." + echo "Re-run this workflow with publish=true to publish." diff --git a/README.md b/README.md index ffc0827..843ce2e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Paste a screenshot in your browser, your agent gets the URL instantly. | [pi](https://github.com/mariozechner/pi) | [`pi/`](pi/) | `@modemdev/glance-pi` | `pi install npm:@modemdev/glance-pi` | | [OpenCode](https://github.com/anomalyco/opencode) | [`opencode/`](opencode/) | `@modemdev/glance-opencode` | Add `"@modemdev/glance-opencode"` to `opencode.json` `plugin` list | | [Claude Code](https://github.com/anthropics/claude-code) | [`claude/`](claude/) | `@modemdev/glance-claude` | `/plugin marketplace add modem-dev/glance-agent-plugins` then `/plugin install glance-claude@glance-agent-plugins` | -| [Codex](https://developers.openai.com/codex) | [`codex/`](codex/) | n/a | `codex mcp add glance -- node /absolute/path/to/codex/servers/glance-mcp.js` | +| [Codex](https://developers.openai.com/codex) | [`codex/`](codex/) | `@modemdev/glance-codex` | `codex mcp add glance -- npx -y @modemdev/glance-codex` | ## How it works diff --git a/codex/README.md b/codex/README.md index 7779845..cd87d30 100644 --- a/codex/README.md +++ b/codex/README.md @@ -13,13 +13,19 @@ The server keeps a background SSE listener alive, reconnects automatically, and ## Install -From this repository: +Recommended (npm package): ```bash -codex mcp add glance -- node "$(pwd)/codex/servers/glance-mcp.js" +codex mcp add glance -- npx -y @modemdev/glance-codex ``` -Or with an absolute path from anywhere: +Optional: pin a specific version: + +```bash +codex mcp add glance -- npx -y @modemdev/glance-codex@0.1.0 +``` + +Local development / manual install: ```bash codex mcp add glance -- node /absolute/path/to/glance-agent-plugins/codex/servers/glance-mcp.js @@ -41,11 +47,11 @@ codex mcp get glance --json ## Update / remove -Update to the latest plugin code: +Update: ```bash codex mcp remove glance -codex mcp add glance -- node /absolute/path/to/glance-agent-plugins/codex/servers/glance-mcp.js +codex mcp add glance -- npx -y @modemdev/glance-codex ``` Remove: @@ -54,6 +60,23 @@ Remove: codex mcp remove glance ``` +## Publishing (maintainers) + +Releases are automated via GitHub Actions. + +Prerequisite: configure `NPM_TOKEN` in the `glance-agent-plugins` repository with publish access to `@modemdev/glance-codex`. + +1. Bump `version` in `codex/package.json`. +2. Commit and push to `main`. +3. Create and push a matching tag: + +```bash +git tag codex-v0.1.0 +git push origin codex-v0.1.0 +``` + +The `Release codex package` workflow validates tag/version alignment, checks for already-published versions, runs `npm pack --dry-run`, and publishes with npm provenance. + ## How it works ```text diff --git a/codex/glance.test.ts b/codex/glance.test.ts index ac163be..0a19040 100644 --- a/codex/glance.test.ts +++ b/codex/glance.test.ts @@ -33,14 +33,20 @@ describe("codex glance plugin", () => { vi.restoreAllMocks() }) - it("ships codex plugin manifest and MCP config", () => { + it("ships codex plugin manifest, MCP config, and npm package metadata", () => { const pluginJsonPath = fileURLToPath(new URL("./.codex-plugin/plugin.json", import.meta.url)) const mcpJsonPath = fileURLToPath(new URL("./.mcp.json", import.meta.url)) + const packageJsonPath = fileURLToPath(new URL("./package.json", import.meta.url)) const manifest = JSON.parse(readFileSync(pluginJsonPath, "utf8")) as Record const mcpConfig = JSON.parse(readFileSync(mcpJsonPath, "utf8")) as { mcpServers?: Record> } + const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { + name?: string + bin?: Record + main?: string + } expect(manifest.name).toBe("glance-codex") expect(manifest.mcpServers).toBe("./.mcp.json") @@ -48,6 +54,10 @@ describe("codex glance plugin", () => { expect(mcpConfig.mcpServers?.glance?.command).toBe("node") expect(mcpConfig.mcpServers?.glance?.args).toEqual(["servers/glance-mcp.js"]) expect(mcpConfig.mcpServers?.glance?.cwd).toBe(".") + + expect(packageJson.name).toBe("@modemdev/glance-codex") + expect(packageJson.main).toBe("servers/glance-mcp.js") + expect(packageJson.bin?.["glance-codex"]).toBe("servers/glance-mcp.js") }) it("returns a session URL from glance", async () => { diff --git a/codex/package.json b/codex/package.json new file mode 100644 index 0000000..1093cec --- /dev/null +++ b/codex/package.json @@ -0,0 +1,34 @@ +{ + "name": "@modemdev/glance-codex", + "version": "0.1.0", + "description": "glance.sh MCP server package for Codex", + "license": "MIT", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/modem-dev/glance-agent-plugins.git", + "directory": "codex" + }, + "homepage": "https://github.com/modem-dev/glance-agent-plugins/tree/main/codex", + "bugs": { + "url": "https://github.com/modem-dev/glance-agent-plugins/issues" + }, + "keywords": [ + "codex", + "mcp", + "glance", + "screenshot", + "agent" + ], + "files": [ + ".codex-plugin", + ".mcp.json", + "servers/glance-mcp.js", + "servers/glance-mcp.d.ts", + "README.md" + ], + "main": "servers/glance-mcp.js", + "bin": { + "glance-codex": "servers/glance-mcp.js" + } +} diff --git a/codex/servers/glance-mcp.d.ts b/codex/servers/glance-mcp.d.ts index c9deb5f..f3295b0 100644 --- a/codex/servers/glance-mcp.d.ts +++ b/codex/servers/glance-mcp.d.ts @@ -1,19 +1,33 @@ -import type { - GlanceRuntime, - McpRuntime, - McpServer, -} from "../../claude/servers/glance-mcp.js" +export type McpTextContent = { type: string; text: string } -export { createGlanceRuntime, createMcpServer } from "../../claude/servers/glance-mcp.js" +export interface ToolResult { + content: McpTextContent[] + structuredContent?: Record + isError?: boolean +} -export interface CodexMcpServerOptions { - runtime?: McpRuntime - runtimeOptions?: Parameters< - typeof import("../../claude/servers/glance-mcp.js").createGlanceRuntime - >[0] - serverOptions?: Parameters< - typeof import("../../claude/servers/glance-mcp.js").createMcpServer - >[0] +export interface GlanceRuntime { + executeTool(name: string, args?: Record, signal?: AbortSignal): Promise + getTools(): Array<{ + name: string + description: string + inputSchema: Record + }> + getState(): { + currentSession: { id: string; url: string } | null + running: boolean + sessionCreatedAt: number + waiterCount: number + } + startBackground(): void + stopBackground(): void +} + +export interface McpServer { + handleData(chunk: Buffer | string): void + handleMessage(message: Record): Promise + start(): void + stop(): void } export function createGlanceRuntime(options?: { @@ -23,6 +37,20 @@ export function createGlanceRuntime(options?: { quietLogs?: boolean }): GlanceRuntime +export interface McpRuntime { + getTools(): Array<{ + name: string + description: string + inputSchema: Record + }> + executeTool( + name: string, + args?: Record, + signal?: AbortSignal, + ): Promise + stopBackground(): void +} + export function createMcpServer(options?: { runtime?: McpRuntime stdin?: NodeJS.ReadableStream @@ -34,4 +62,8 @@ export function createMcpServer(options?: { quietLogs?: boolean }): McpServer -export function createCodexMcpServer(options?: CodexMcpServerOptions): McpServer +export function createCodexMcpServer(options?: { + runtime?: McpRuntime + runtimeOptions?: Parameters[0] + serverOptions?: Parameters[0] +}): McpServer diff --git a/codex/servers/glance-mcp.js b/codex/servers/glance-mcp.js old mode 100644 new mode 100755 index 9a7b140..10564a8 --- a/codex/servers/glance-mcp.js +++ b/codex/servers/glance-mcp.js @@ -1,11 +1,731 @@ +#!/usr/bin/env node + import { pathToFileURL } from "node:url" -import { - createGlanceRuntime, - createMcpServer, -} from "../../claude/servers/glance-mcp.js" +const DEFAULT_BASE_URL = process.env.GLANCE_BASE_URL?.trim() || "https://glance.sh" + +/** How long to wait on one SSE connection before reconnecting. */ +const SSE_TIMEOUT_MS = 305_000 + +/** Pause between reconnect attempts on transient errors. */ +const RECONNECT_DELAY_MS = 3_000 + +/** How often to mint a fresh session (sessions have 10-minute TTL). */ +const SESSION_REFRESH_MS = 8 * 60 * 1000 + +const WAITER_PREFIX = "glance_waiter_" + +const TOOL_DEFINITIONS = [ + { + name: "glance", + description: + "Open a live glance.sh session so the user can paste a screenshot from their browser. " + + "Return the session URL for the user to open, then call glance_wait to block for the next image. " + + "Use this when you need to see the user's screen, a UI, an error dialog, or anything visual.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + }, + { + name: "glance_wait", + description: + "Wait for the user to paste an image into the active glance.sh session and return the image URL. " + + "Call glance first to get the session URL, share it with the user, then call this tool.", + inputSchema: { + type: "object", + properties: {}, + additionalProperties: false, + }, + }, +] + +function normalizeSessionUrl(url, baseUrl) { + return new URL(url, baseUrl).toString() +} + +function toTextResult(text, extra = {}) { + return { + content: [{ type: "text", text }], + ...extra, + } +} + +function sleep(ms, signal) { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + signal?.removeEventListener("abort", onAbort) + resolve() + }, ms) + + const onAbort = () => { + clearTimeout(timeout) + signal?.removeEventListener("abort", onAbort) + resolve() + } + + signal?.addEventListener("abort", onAbort, { once: true }) + }) +} + +export function createGlanceRuntime(options = {}) { + const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL + const fetchImpl = options.fetchImpl ?? globalThis.fetch + const log = + options.log ?? + ((message) => { + if (!options.quietLogs) { + process.stderr.write(`[glance-mcp] ${message}\n`) + } + }) + + if (!fetchImpl) { + throw new Error("Fetch API is required (Node 18+)") + } + + let currentSession = null + let sessionCreatedAt = 0 + let running = false + let abortController = null + let createSessionPromise = null + let waiterCounter = 0 + + const waiters = new Map() + + function nextWaiterKey() { + waiterCounter += 1 + return `${WAITER_PREFIX}${Date.now()}_${waiterCounter}` + } + + function isSessionStale() { + return Date.now() - sessionCreatedAt > SESSION_REFRESH_MS + } + + function getSessionUrl() { + return currentSession?.url + } + + async function createSession() { + const res = await fetchImpl(`${baseUrl}/api/session`, { method: "POST" }) + if (!res.ok) { + throw new Error(`HTTP ${res.status}`) + } + + const session = await res.json() + + if (!session || typeof session.id !== "string" || typeof session.url !== "string") { + throw new Error("Invalid session response") + } + + currentSession = { + id: session.id, + url: normalizeSessionUrl(session.url, baseUrl), + } + sessionCreatedAt = Date.now() + return currentSession + } + + async function ensureSession() { + if (currentSession && !isSessionStale()) { + return currentSession + } + + if (!createSessionPromise) { + createSessionPromise = createSession().finally(() => { + createSessionPromise = null + }) + } + + return await createSessionPromise + } + + function dispatchToWaiters(image) { + for (const resolve of [...waiters.values()]) { + resolve(image) + } + } + + function clearWaiters() { + for (const resolve of [...waiters.values()]) { + resolve(null) + } + } + + async function listenForImages(sessionId, signal) { + const res = await fetchImpl(`${baseUrl}/api/session/${sessionId}/events`, { + signal, + headers: { Accept: "text/event-stream" }, + }) + + if (!res.ok || !res.body) { + throw new Error(`SSE connect failed: HTTP ${res.status}`) + } + + const reader = res.body.getReader() + const decoder = new TextDecoder() + + let buffer = "" + let eventType = "" + let dataLines = [] + + const timeout = setTimeout(() => { + reader.cancel().catch(() => {}) + }, SSE_TIMEOUT_MS) + + const onAbort = () => { + clearTimeout(timeout) + reader.cancel().catch(() => {}) + } + signal.addEventListener("abort", onAbort, { once: true }) + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split("\n") + buffer = lines.pop() ?? "" + + for (const rawLine of lines) { + const line = rawLine.endsWith("\r") ? rawLine.slice(0, -1) : rawLine + + if (line.startsWith("event:")) { + eventType = line.slice(6).trim() + continue + } + + if (line.startsWith("data:")) { + dataLines.push(line.slice(5).trimStart()) + continue + } + + if (line !== "") { + continue + } + + if (eventType === "image" && dataLines.length > 0) { + try { + const image = JSON.parse(dataLines.join("\n")) + + if ( + image && + typeof image.url === "string" && + typeof image.expiresAt === "number" + ) { + dispatchToWaiters(image) + } + } catch { + log("Failed to parse image event payload") + } + } + + if (eventType === "expired") { + currentSession = null + return + } + + if (eventType === "timeout") { + return + } + + eventType = "" + dataLines = [] + } + } + } finally { + clearTimeout(timeout) + signal.removeEventListener("abort", onAbort) + } + } + + async function backgroundLoop(signal) { + while (!signal.aborted) { + try { + const session = await ensureSession() + await listenForImages(session.id, signal) + } catch (err) { + if (signal.aborted) break + await sleep(RECONNECT_DELAY_MS, signal) + } + } + + running = false + } + + function startBackground() { + if (running) return + + running = true + abortController = new AbortController() + + backgroundLoop(abortController.signal).catch((err) => { + log(`Background loop error: ${err instanceof Error ? err.message : String(err)}`) + running = false + }) + } + + function stopBackground() { + abortController?.abort() + abortController = null + currentSession = null + running = false + clearWaiters() + } + + function waitForNextImage(signal) { + return new Promise((resolve) => { + if (!currentSession) { + resolve(null) + return + } + + if (signal?.aborted) { + resolve(null) + return + } + + const key = nextWaiterKey() + + const timeout = setTimeout(() => { + signal?.removeEventListener("abort", onAbort) + waiters.delete(key) + resolve(null) + }, SSE_TIMEOUT_MS) + + const finish = (image) => { + clearTimeout(timeout) + signal?.removeEventListener("abort", onAbort) + waiters.delete(key) + resolve(image) + } + + const onAbort = () => finish(null) + + waiters.set(key, finish) + signal?.addEventListener("abort", onAbort, { once: true }) + }) + } + + async function executeTool(name, args = {}, signal) { + if (name === "glance") { + try { + const session = await ensureSession() + startBackground() + + const sessionUrl = session.url + + return toTextResult( + `Session ready. Ask the user to paste an image at ${sessionUrl}. Then call glance_wait.`, + { + structuredContent: { + sessionUrl, + }, + }, + ) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + + return toTextResult(`Failed to create session: ${message}`, { + isError: true, + structuredContent: { error: message }, + }) + } + } + + if (name === "glance_wait") { + if (!currentSession) { + return toTextResult("No active session. Call glance first to create one.", { + isError: true, + structuredContent: { error: "no_active_session" }, + }) + } + + startBackground() + + const sessionUrl = currentSession.url + const image = await waitForNextImage(signal) + + if (!image) { + if (signal?.aborted) { + return toTextResult("Cancelled", { + structuredContent: { + sessionUrl, + error: "cancelled", + }, + }) + } + + return toTextResult(`Session timed out. Ask the user to paste an image at ${sessionUrl}`, { + structuredContent: { + sessionUrl, + error: "timeout", + }, + }) + } + + return toTextResult(`Screenshot: ${image.url}`, { + structuredContent: { + sessionUrl, + imageUrl: image.url, + expiresAt: image.expiresAt, + }, + }) + } + + return toTextResult(`Unknown tool: ${name}`, { + isError: true, + structuredContent: { error: "unknown_tool" }, + }) + } + + return { + executeTool, + getTools() { + return TOOL_DEFINITIONS + }, + getState() { + return { + currentSession, + running, + sessionCreatedAt, + waiterCount: waiters.size, + } + }, + startBackground, + stopBackground, + } +} + +function toError(id, code, message, data) { + const error = { + code, + message, + } + + if (data !== undefined) { + error.data = data + } + + return { + jsonrpc: "2.0", + id, + error, + } +} + +function toResult(id, result) { + return { + jsonrpc: "2.0", + id, + result, + } +} + +function isRequest(message) { + return message && typeof message === "object" && "id" in message +} + +export function createMcpServer(options = {}) { + const runtime = options.runtime ?? createGlanceRuntime() + const stdin = options.stdin ?? process.stdin + const stdout = options.stdout ?? process.stdout + const stderr = options.stderr ?? process.stderr + const exit = options.exit ?? ((code) => process.exit(code)) + const log = + options.log ?? + ((message) => { + if (!options.quietLogs) { + stderr.write(`[glance-mcp] ${message}\n`) + } + }) + + const inFlight = new Map() + let readBuffer = Buffer.alloc(0) + let started = false + let outputMode = options.outputMode === "line" ? "line" : "framed" + + const sendMessage = + options.sendMessage ?? + ((message) => { + const payload = JSON.stringify(message) + + if (outputMode === "line") { + stdout.write(`${payload}\n`) + return + } + + const frame = `Content-Length: ${Buffer.byteLength(payload, "utf8")}\r\n\r\n${payload}` + stdout.write(frame) + }) + + function sendResult(id, result) { + sendMessage(toResult(id, result)) + } + + function sendError(id, code, message, data) { + sendMessage(toError(id, code, message, data)) + } + + async function handleRequest(message) { + const { id, method, params } = message + + if (method === "initialize") { + sendResult(id, { + protocolVersion: params?.protocolVersion ?? "2024-11-05", + serverInfo: { + name: "glance-sh", + version: "0.1.0", + }, + capabilities: { + tools: { + listChanged: false, + }, + }, + }) + return + } + + if (method === "ping") { + sendResult(id, {}) + return + } + + if (method === "tools/list") { + sendResult(id, { + tools: runtime.getTools(), + }) + return + } + + if (method === "tools/call") { + const toolName = params?.name + const toolArgs = params?.arguments ?? {} + + if (typeof toolName !== "string" || toolName.length === 0) { + sendError(id, -32602, "Invalid params: expected tool name") + return + } + + const abortController = new AbortController() + inFlight.set(id, abortController) + + try { + const result = await runtime.executeTool(toolName, toolArgs, abortController.signal) + sendResult(id, result) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + sendError(id, -32000, "Tool execution failed", { message }) + } finally { + inFlight.delete(id) + } + + return + } -export { createGlanceRuntime, createMcpServer } + if (method === "shutdown") { + runtime.stopBackground() + sendResult(id, {}) + return + } + + sendError(id, -32601, `Method not found: ${method}`) + } + + function handleNotification(message) { + if (message.method === "notifications/cancelled") { + const requestId = message.params?.requestId + const controller = inFlight.get(requestId) + controller?.abort() + return + } + + if ( + message.method === "notifications/initialized" || + message.method === "notifications/tools/list_changed" + ) { + return + } + + if (message.method === "exit" || message.method === "notifications/exit") { + stop() + exit(0) + } + } + + async function handleMessage(message) { + if (!message || typeof message !== "object" || message.jsonrpc !== "2.0") { + sendError(null, -32600, "Invalid Request") + return + } + + if (typeof message.method !== "string") { + sendError(isRequest(message) ? message.id : null, -32600, "Invalid Request") + return + } + + if (isRequest(message)) { + await handleRequest(message) + return + } + + handleNotification(message) + } + + function readHeaderFrame(buffer) { + const crlfEnd = buffer.indexOf("\r\n\r\n") + const lfEnd = buffer.indexOf("\n\n") + + let headerEnd = -1 + let separatorLength = 0 + + if (crlfEnd !== -1 && (lfEnd === -1 || crlfEnd < lfEnd)) { + headerEnd = crlfEnd + separatorLength = 4 + } else if (lfEnd !== -1) { + headerEnd = lfEnd + separatorLength = 2 + } + + if (headerEnd === -1) { + return null + } + + const header = buffer.slice(0, headerEnd).toString("utf8") + const lengthMatch = header.match(/content-length:\s*(\d+)/i) + + if (!lengthMatch) { + return { + error: "missing_content_length", + } + } + + const contentLength = Number(lengthMatch[1]) + const messageStart = headerEnd + separatorLength + const messageEnd = messageStart + contentLength + + if (buffer.length < messageEnd) { + return null + } + + return { + payload: buffer.slice(messageStart, messageEnd).toString("utf8"), + rest: buffer.slice(messageEnd), + } + } + + function readLineFrame(buffer) { + const text = buffer.toString("utf8") + const trimmedStart = text.trimStart() + + if (!(trimmedStart.startsWith("{") || trimmedStart.startsWith("["))) { + return null + } + + const newlineIndex = text.indexOf("\n") + + if (newlineIndex === -1) { + return null + } + + const line = text.slice(0, newlineIndex).trim() + + return { + payload: line.length > 0 ? line : null, + rest: Buffer.from(text.slice(newlineIndex + 1), "utf8"), + } + } + + function dispatchMessagePayload(payload) { + if (!payload) { + return + } + + let message + + try { + message = JSON.parse(payload) + } catch { + sendError(null, -32700, "Parse error") + return + } + + handleMessage(message).catch((err) => { + const msg = err instanceof Error ? err.message : String(err) + log(`Request handler failure: ${msg}`) + + if (isRequest(message)) { + sendError(message.id, -32603, "Internal error") + } + }) + } + + function handleData(chunk) { + const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + readBuffer = Buffer.concat([readBuffer, chunkBuffer]) + + while (true) { + const headerFrame = readHeaderFrame(readBuffer) + + if (headerFrame?.error === "missing_content_length") { + readBuffer = Buffer.alloc(0) + sendError(null, -32600, "Invalid Request: missing Content-Length") + return + } + + if (headerFrame?.payload !== undefined) { + outputMode = "framed" + readBuffer = headerFrame.rest + dispatchMessagePayload(headerFrame.payload) + continue + } + + const lineFrame = readLineFrame(readBuffer) + + if (lineFrame) { + outputMode = "line" + readBuffer = lineFrame.rest + dispatchMessagePayload(lineFrame.payload) + continue + } + + return + } + } + + function stop() { + if (!started) return + + stdin.off("data", handleData) + runtime.stopBackground() + + for (const controller of inFlight.values()) { + controller.abort() + } + + inFlight.clear() + started = false + } + + function start() { + if (started) return + + started = true + stdin.on("data", handleData) + + if (typeof stdin.resume === "function") { + stdin.resume() + } + } + + return { + handleData, + handleMessage, + start, + stop, + } +} export function createCodexMcpServer(options = {}) { const runtime = options.runtime ?? createGlanceRuntime(options.runtimeOptions)