# Directory Structure
```
├── .changeset
│ ├── commit.cjs
│ ├── config.json
│ ├── full-parents-draw.md
│ ├── honest-bobcats-speak.md
│ ├── README.md
│ └── thirty-melons-jog.md
├── .github
│ └── workflows
│ ├── build-cli.yml
│ ├── format.yml
│ └── release.yml
├── .gitignore
├── bun.lock
├── bunfig.toml
├── cmd
│ └── cli.go
├── download_cli.sh
├── examples
│ ├── airtabl3
│ │ ├── index.ts
│ │ ├── lib
│ │ │ └── index.ts
│ │ └── package.json
│ ├── echo
│ │ ├── bun.lock
│ │ ├── index.ts
│ │ └── package.json
│ └── systeminfo
│ ├── bun.lock
│ ├── index.ts
│ ├── lib
│ │ └── info.ts
│ └── package.json
├── expose.go
├── go.mod
├── go.sum
├── makefile
├── package.json
├── packages
│ └── expose
│ ├── CHANGELOG.md
│ ├── package.json
│ ├── src
│ │ ├── index.ts
│ │ ├── mcp.ts
│ │ └── tool.ts
│ └── tsconfig.json
├── README.md
└── scripts
├── format
└── publish
```
# Files
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
tmp/
```
--------------------------------------------------------------------------------
/.changeset/README.md:
--------------------------------------------------------------------------------
```markdown
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
---
title: Expose
description: Easily build MCP tools for Claude desktop app
---
Expose lets you build MCP tools that you can invoke with MCP client like Claude desktop app.
- **Self-hosted**: You can easily self-host tools and deploy them on your own server.
- **Unified gateway**: Generates a single HTTP endpoint that you can register with `expose-cli`
- **Flexible**: Easily configure and customize your tools to fit your needs.
## Getting started
1. **Setup expose CLI**
```bash
curl -fsSL https://github.com/a0dotrun/expose/releases/download/stable/download_cli.sh | bash
```
2. **Install dependencies**
```bash
npm i @a0dotrun/expose
```
3. **Create server**
```bash
touch src/server.ts
```
```ts title=src/server.ts
import { create } from "@a0dotrun/expose"
const app = create({
tools: [],
})
export default {
port: 3000,
fetch: app.fetch,
}
```
4. **Define your tools**
```diff lang=ts title=src/server.ts
+ import { tool } from "@a0dotrun/expose/tool"
+ import { subscription } from "@acme/lib/subscription"
+ import { z } from "zod"
+ const getCustomerSubscription = tool({
+ name: "getCustomerSubscription",
+ description: "Get subscription information for a customer",
+ args: z.object({
+ customer: z.string().uuid()
+ }),
+ run: async (input) => {
+ // Your subscription logic here
+ return input;
+ },
+ });
+ const createCustomerSubscription = tool({
+ name: "createCustomerSubscription",
+ description: "Create a subscription for a customer",
+ args: z.object({
+ customer: z.string().uuid()
+ }),
+ run: async (input) => {
+ // Your subscription logic here
+ return input;
+ },
+ });
const app = create({
tools: [
+ getCustomerSubscription,
+ createCustomerSubscription,
],
});
```
5. **Start server**
```bash
npm run dev
```
You can also deploy the server and note down the public URL.
6. **Register in Claude desktop app**
MACOS Claude desktop MCP config path: `~/Library/Application Support/Claude/claude_desktop_config.json`
```json
{
...
"mcpServers": {
"subscriptionManager": {
"command": "/Users/acmeuser/.local/bin/expose-cli",
"args": ["--url", "http://localhost:3000", "--timeout", "15"]
}
}
...
}
```
_replace localhost with your public URL_
---
expose is created by [@\_sanchitrk](https://x.com/_sanchitrk) at [a0](https://a0.run)
### Acknowledgements
I was inspired by [opencontrol](https://github.com/toolbeam/opencontrol)
```
--------------------------------------------------------------------------------
/bunfig.toml:
--------------------------------------------------------------------------------
```toml
[install]
exact = true
```
--------------------------------------------------------------------------------
/.changeset/thirty-melons-jog.md:
--------------------------------------------------------------------------------
```markdown
---
"@a0dotrun/expose": patch
---
tweaks
```
--------------------------------------------------------------------------------
/.changeset/full-parents-draw.md:
--------------------------------------------------------------------------------
```markdown
---
"@a0dotrun/expose": patch
---
updated install script
```
--------------------------------------------------------------------------------
/.changeset/honest-bobcats-speak.md:
--------------------------------------------------------------------------------
```markdown
---
"@a0dotrun/expose": patch
---
fixes install expose-cli script
```
--------------------------------------------------------------------------------
/examples/echo/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "@expose/example-echo",
"private": true,
"version": "0.0.0",
"dependencies": {
"@a0dotrun/expose": "workspace:*"
}
}
```
--------------------------------------------------------------------------------
/examples/systeminfo/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "@expose/example-systeminfo",
"private": true,
"version": "0.0.0",
"dependencies": {
"@a0dotrun/expose": "workspace:*"
}
}
```
--------------------------------------------------------------------------------
/.changeset/commit.cjs:
--------------------------------------------------------------------------------
```
/** @type {import('@changesets/types').CommitFunctions["getAddMessage"]} */
module.exports.getAddMessage = async (changeset) => {
return changeset.summary;
};
```
--------------------------------------------------------------------------------
/examples/airtabl3/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "@expose/example-airtabl3",
"private": true,
"version": "0.0.0",
"dependencies": {
"@a0dotrun/expose": "workspace:*",
"airtable": "0.12.2"
}
}
```
--------------------------------------------------------------------------------
/packages/expose/tsconfig.json:
--------------------------------------------------------------------------------
```json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true
}
}
```
--------------------------------------------------------------------------------
/packages/expose/CHANGELOG.md:
--------------------------------------------------------------------------------
```markdown
# expose
## 0.0.4
### Patch Changes
- 3a6f233: gh tweaks
## 0.0.3
### Patch Changes
- a8abced: fix package naming
## 0.0.2
### Patch Changes
- 35fe442: v0 working example with examples
```
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
```json
{
"$schema": "https://unpkg.com/@changesets/[email protected]/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": "./commit.cjs",
"fixed": [["@a0dotrun/expose"]],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["@expose/example-*"]
}
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
{
"name": "expose",
"private": true,
"type": "module",
"packageManager": "bun",
"workspaces": [
"packages/*",
"examples/*"
],
"devDependencies": {
"@changesets/cli": "2.28.1",
"@tsconfig/node22": "22.0.0",
"@types/node": "22.13.9",
"prettier": "3.5.3",
"typescript": "^5"
},
"prettier": {
"semi": false
}
}
```
--------------------------------------------------------------------------------
/examples/systeminfo/lib/info.ts:
--------------------------------------------------------------------------------
```typescript
import os from "os"
export function serverInfo() {
return {
hostname: os.hostname(),
platform: os.platform(),
arch: os.arch(),
uptime: os.uptime(),
totalMemory: os.totalmem(),
freeMemory: os.freemem(),
cpus: os.cpus().map((cpu) => ({
model: cpu.model,
speed: cpu.speed,
})),
networkInterfaces: os.networkInterfaces(),
loadAvg: os.loadavg(),
}
}
```
--------------------------------------------------------------------------------
/examples/echo/index.ts:
--------------------------------------------------------------------------------
```typescript
import { create } from "@a0dotrun/expose"
import { tool } from "@a0dotrun/expose/tool"
import { z } from "zod"
const echo = tool({
name: "echo",
description: "echoes the message back",
args: z.object({
message: z.string(),
}),
async run(args) {
return "Echo from server: " + args.message
},
})
const app = create({
tools: [echo],
})
export default {
port: 3000,
fetch: app.fetch,
}
```
--------------------------------------------------------------------------------
/examples/systeminfo/index.ts:
--------------------------------------------------------------------------------
```typescript
import { create } from "@a0dotrun/expose"
import { tool } from "@a0dotrun/expose/tool"
import { serverInfo } from "./lib/info"
const systemInfo = tool({
name: "systemInfo",
description:
"Get sytem information about the server like uptime, memory, cpu, etc.",
run: async () => {
return serverInfo()
},
})
const app = create({
tools: [systemInfo],
})
export default {
port: 3000,
fetch: app.fetch,
}
```
--------------------------------------------------------------------------------
/.github/workflows/format.yml:
--------------------------------------------------------------------------------
```yaml
name: Format and Lint
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
jobs:
format:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref }}
fetch-depth: 0
- uses: oven-sh/setup-bun@v2
- run: |
git config --local user.email "[email protected]"
git config --local user.name "gh-actions-bot"
make format
```
--------------------------------------------------------------------------------
/packages/expose/package.json:
--------------------------------------------------------------------------------
```json
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@a0dotrun/expose",
"version": "0.0.4",
"type": "module",
"scripts": {
"build": "bun tsc"
},
"exports": {
".": "./dist/index.js",
"./*": "./dist/*.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "1.6.1",
"@tsconfig/bun": "1.0.7",
"hono": "4.7.4",
"zod": "3.24.2",
"zod-to-json-schema": "3.24.3"
},
"devDependencies": {
"@standard-schema/spec": "1.0.0"
},
"publishConfig": {
"access": "public"
}
}
```
--------------------------------------------------------------------------------
/packages/expose/src/tool.ts:
--------------------------------------------------------------------------------
```typescript
import { StandardSchemaV1 } from "@standard-schema/spec"
import { z } from "zod"
export interface Tool<
Args extends undefined | StandardSchemaV1 = undefined | StandardSchemaV1,
> {
name: string
description: string
args?: Args
run: Args extends StandardSchemaV1
? (args: StandardSchemaV1.InferOutput<Args>) => Promise<any>
: () => Promise<any>
}
export function tool<Args extends undefined | StandardSchemaV1>(
input: Tool<Args>,
) {
return input
}
// Example
tool({
name: "echo",
description: "echoes the message back",
args: z.object({
message: z.string(),
}),
async run(args) {
return args.message
},
})
```
--------------------------------------------------------------------------------
/packages/expose/src/index.ts:
--------------------------------------------------------------------------------
```typescript
import { Hono } from "hono"
// import { HTTPException } from "hono/http-exception"
import { Tool } from "./tool.js"
import { createMcp } from "./mcp.js"
export interface ExposeOptions {
token?: string
}
export function create(input: { tools: Tool[]; key?: string }) {
const mcp = createMcp({ tools: input.tools })
return (
new Hono()
// .use((c, next) => {
// if (input?.key) {
// const authorization = c.req.header("Authorization")
// if (authorization !== `Bearer ${input?.key}`) {
// throw new HTTPException(401)
// }
// }
// return next()
// })
.post("/", async (c) => {
const body = await c.req.json()
console.log("mcp request", body)
const result = await mcp.process(body)
console.log("mcp response", result)
return c.json(result)
})
)
}
```
--------------------------------------------------------------------------------
/cmd/cli.go:
--------------------------------------------------------------------------------
```go
package main
import (
"fmt"
"log"
"os"
"time"
"github.com/a0dotrun/expose"
"github.com/spf13/cobra"
)
func main() {
var url string
var timeout int
rootCmd := &cobra.Command{
Use: "expose-cli",
Short: "A CLI tool for proxying MCP tools",
Long: `expose-cli is a command-line tool that proxies MCP tools to a specified URL with configurations`,
Run: func(cmd *cobra.Command, args []string) {
if url == "" {
fmt.Println("Error: URL is required")
cmd.Help()
os.Exit(1)
}
timeoutDuration := time.Duration(timeout) * time.Second
if err := expose.ProxyServeStdio(url, timeoutDuration); err != nil {
log.Fatalf("Failed to start proxy server: %v", err)
}
},
}
// Define flags
rootCmd.Flags().StringVar(&url, "url", "", "Target URL to proxy (required)")
rootCmd.Flags().IntVar(&timeout, "timeout", 10, "Connection timeout in seconds")
// Execute
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)
}
}
```
--------------------------------------------------------------------------------
/examples/airtabl3/index.ts:
--------------------------------------------------------------------------------
```typescript
import { create } from "@a0dotrun/expose"
import { tool } from "@a0dotrun/expose/tool"
import { z } from "zod"
import { createRecord, fetchRecords, updateRecord } from "./lib/index"
const StageSchema = z.enum(["Prospect", "Qualified", "Closed"])
const createCRMRecord = tool({
name: "create_sales_crm_record",
description: "Creates a new record in Sales CRM and generates a unique ID",
args: z.object({
email: z.string().email(),
company: z.string(),
stage: StageSchema,
contact: z.string(),
notes: z.string(),
}),
async run(args) {
const { data, error } = await createRecord({
Email: args.email,
Company: args.company,
Stage: args.stage,
Contact: args.contact,
Notes: args.notes,
})
if (error) {
throw new Error(error.message)
}
return data
},
})
const listCRMRecords = tool({
name: "list_sales_crm_records",
description: "Lists all sales CRM records",
async run() {
const { data, error } = await fetchRecords()
if (error) {
throw new Error(error.message)
}
return data
},
})
const updateCRMRecord = tool({
name: "update_sales_crm_record",
description: "Updates an existing record in Sales CRM",
args: z.object({
id: z.string(),
email: z.string().email().optional(),
company: z.string().optional(),
stage: StageSchema.optional(),
contact: z.string().optional(),
notes: z.string().optional(),
}),
async run(args) {
const { id, ...others } = args
const { data, error } = await updateRecord(id, {
Email: others.email,
Company: others.company,
Stage: others.stage,
Contact: others.contact,
Notes: others.notes,
})
if (error) {
throw new Error(error.message)
}
return data
},
})
const app = create({
tools: [createCRMRecord, listCRMRecords, updateCRMRecord],
})
export default {
port: 3000,
fetch: app.fetch,
}
```
--------------------------------------------------------------------------------
/examples/airtabl3/lib/index.ts:
--------------------------------------------------------------------------------
```typescript
import Airtable from "airtable"
const base = new Airtable({
apiKey: `...`,
}).base("...")
interface NewRecord {
Email: string
Company: string
Stage: string
Contact: string
Notes: string
}
interface UpdateRecord {
Email?: string
Company?: string
Stage?: string
Contact?: string
Notes?: string
}
export async function fetchRecords() {
try {
const records = await base("Default").select({ view: "Grid view" }).all()
const results = records.map((record) => ({
id: record.id,
company: record.fields.Company,
stage: record.fields.Stage,
contact: record.fields.Contact,
notes: record.fields.Notes,
email: record.fields.Email,
}))
return {
data: results,
error: null,
}
} catch (err) {
console.error(err)
return {
data: null,
error: err,
}
}
}
export async function createRecord(args: NewRecord) {
try {
const record = await base("Default").create({
Email: args.Email,
Company: args.Company,
Stage: args.Stage,
Contact: args.Contact,
Notes: args.Notes,
})
return {
data: record.id,
error: null,
}
} catch (err) {
console.error(err)
return {
data: null,
error: err,
}
}
}
export async function updateRecord(id: string, args: UpdateRecord) {
try {
const record = await base("Default").update(id, {
Email: args.Email || undefined,
Company: args.Company || undefined,
Stage: args.Stage || undefined,
Contact: args.Contact || undefined,
Notes: args.Notes || undefined,
})
return {
data: record.id,
error: null,
}
} catch (err) {
console.error(err)
return {
data: null,
error: err,
}
}
}
// createRecord({
// Email: "[email protected]",
// Company: "Aravind Srinivasan",
// Stage: "Prospect",
// Contact: "Aravind Srinivasan",
// Notes: "AI search engine",
// })
// ;(async () => {
// const results = await fetchRecords()
// console.log(results)
// })()
// updateRecord("rec9FCaEJEcis2fub", { Contact: "Amit Singh" })
```
--------------------------------------------------------------------------------
/download_cli.sh:
--------------------------------------------------------------------------------
```bash
#!/usr/bin/env bash
set -eu
##############################################################################
# Expose CLI Install Script
#
# This script downloads the latest stable 'expose-cli' binary from GitHub releases
# and installs it to your system.
#
# Supported OS: macOS (darwin), Linux
# Supported Architectures: x86_64, arm64
#
# Usage:
# curl -fsSL https://github.com/a0dotrun/expose/releases/download/stable/install.sh | bash
#
##############################################################################
# --- 1) Check for curl ---
if ! command -v curl >/dev/null 2>&1; then
echo "Error: 'curl' is required to download Expose CLI. Please install curl and try again."
exit 1
fi
# --- 2) Detect OS/Architecture ---
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$OS" in
linux) OS_ID="unknown-linux-gnu" ;;
darwin) OS_ID="apple-darwin" ;;
*)
echo "Error: Unsupported OS '$OS'. Expose CLI only supports Linux and macOS."
exit 1
;;
esac
case "$ARCH" in
x86_64|amd64)
ARCH_ID="x86_64"
;;
arm64|aarch64)
ARCH_ID="aarch64"
;;
*)
echo "Error: Unsupported architecture '$ARCH'. Expose CLI supports x86_64 and arm64."
exit 1
;;
esac
# --- 3) Set download URL ---
REPO="a0dotrun/expose"
INSTALL_DIR="${EXPOSE_BIN_DIR:-"$HOME/.local/bin"}"
FILE="expose-cli-${ARCH_ID}-${OS_ID}.tar.bz2"
DOWNLOAD_URL="https://github.com/$REPO/releases/download/stable/$FILE"
# --- 4) Download the binary ---
echo "Downloading $FILE from $DOWNLOAD_URL..."
curl -sLf "$DOWNLOAD_URL" --output "$FILE"
# --- 5) Extract & Install ---
mkdir -p "$INSTALL_DIR"
tar -xjf "$FILE" -C "$INSTALL_DIR"
rm "$FILE"
# Ensure it's executable
chmod +x "$INSTALL_DIR/expose-cli"
echo "✅ Expose CLI installed successfully at $INSTALL_DIR/expose-cli"
# --- 6) Add to PATH if needed ---
if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
echo ""
echo "Warning: Expose CLI installed, but $INSTALL_DIR is not in your PATH."
echo "Add it by running:"
echo " export PATH=\"$INSTALL_DIR:\$PATH\""
echo "Then reload your shell (e.g. 'source ~/.bashrc', 'source ~/.zshrc')."
fi
echo "Run 'expose-cli --help' to get started!"
```
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
```yaml
# This workflow is main release, needs to be manually tagged & pushed.
on:
push:
paths-ignore:
- "docs/**"
tags:
- "v*"
name: Release
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# ------------------------------------
# 1) Build CLI for multiple OS/Arch (Linux, MacOS)
# ------------------------------------
build-cli:
uses: ./.github/workflows/build-cli.yml
# ------------------------------------
# 2) Upload Install CLI Script
# ------------------------------------
install-script:
name: Upload Install Script
runs-on: ubuntu-latest
needs: [build-cli]
steps:
- uses: actions/checkout@v4
- uses: actions/upload-artifact@v4
with:
name: download_cli.sh
path: download_cli.sh
# ------------------------------------
# 3) Create/Update GitHub Release CLI
# ------------------------------------
release-cli:
name: Release CLI
runs-on: ubuntu-latest
needs: [build-cli, install-script]
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
merge-multiple: true
- name: Release versioned
uses: ncipollo/release-action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
artifacts: |
expose-cli-*.tar.bz2
download_cli.sh
allowUpdates: true
omitBody: true
omitPrereleaseDuringUpdate: true
- name: Release CLI Stable
uses: ncipollo/release-action@v1
with:
tag: stable
name: Stable
token: ${{ secrets.GITHUB_TOKEN }}
artifacts: |
expose-cli-*.tar.bz2
download_cli.sh
allowUpdates: true
omitBody: true
omitPrereleaseDuringUpdate: true
# ------------------------------------
# 4) Publish NPM Package
# ------------------------------------
publish-npm:
name: Publish NPM Package
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: bun install
- name: Authenticate NPM
run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
- name: Run publish
run: make publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
```
--------------------------------------------------------------------------------
/.github/workflows/build-cli.yml:
--------------------------------------------------------------------------------
```yaml
# This is a **reuseable** workflow that bundles the CLI
# It doesn't get triggered on its own. It gets used in multiple workflows:
# - release.yml
on:
workflow_call:
inputs:
version:
required: false
default: ""
type: string
# Let's allow overriding the OSes and architectures in JSON array form:
# e.g. '["ubuntu-latest","macos-latest"]'
# If no input is provided, these defaults apply.
operating-systems:
type: string
required: false
default: '["ubuntu-latest","macos-latest"]'
architectures:
type: string
required: false
default: '["x86_64","aarch64"]'
name: "Reusable workflow to build CLI"
jobs:
build-cli:
name: Build CLI
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: ${{ fromJson(inputs.operating-systems) }}
architecture: ${{ fromJson(inputs.architectures) }}
include:
- os: ubuntu-latest
target-suffix: unknown-linux-gnu
goos: linux
os_name: linux
- os: macos-latest
target-suffix: apple-darwin
goos: darwin
os_name: macos
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: stable
- name: Install jq
run: |
if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then
sudo apt-get update && sudo apt-get install -y jq
elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then
brew install jq
fi
- name: Extract version
run: |
VERSION=$(jq -r '.version' packages/expose/package.json)
echo "VERSION=$VERSION" >> $GITHUB_ENV
echo "Extracted version: $VERSION"
- name: Set GOARCH
run: |
if [[ "${{ matrix.architecture }}" == "x86_64" ]]; then
echo "GOARCH=amd64" >> $GITHUB_ENV
elif [[ "${{ matrix.architecture }}" == "aarch64" ]]; then
echo "GOARCH=arm64" >> $GITHUB_ENV
fi
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ env.GOARCH }}
run: |
TARGET="${{ matrix.architecture }}-${{ matrix.target-suffix }}"
echo "Building for target: ${TARGET} (GOOS=$GOOS, GOARCH=$GOARCH)"
# Create output directory
mkdir -p build/${TARGET}/release
# Build the binary
go build -o build/${TARGET}/release/expose-cli cmd/cli.go
# Create tarball
cd build/${TARGET}/release
tar -cjf expose-cli-${TARGET}.tar.bz2 expose-cli
cd -
# Set artifact path for upload step
echo "ARTIFACT=build/${TARGET}/release/expose-cli-${TARGET}.tar.bz2" >> $GITHUB_ENV
echo "Build complete: ${{ env.ARTIFACT }}"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: expose-cli-${{ matrix.architecture }}-${{ matrix.target-suffix }}
path: ${{ env.ARTIFACT }}
```
--------------------------------------------------------------------------------
/packages/expose/src/mcp.ts:
--------------------------------------------------------------------------------
```typescript
import {
CallToolRequestSchema,
CallToolResult,
ErrorCode,
InitializeRequestSchema,
InitializeResult,
JSONRPCError,
JSONRPCRequest,
JSONRPCResponse,
ListToolsRequestSchema,
ListToolsResult,
} from "@modelcontextprotocol/sdk/types.js"
import { z } from "zod"
import { zodToJsonSchema } from "zod-to-json-schema"
import { Tool } from "./tool.js"
class MCPError extends Error {
constructor(
message: string,
public code: ErrorCode,
) {
super(message)
}
}
const RequestSchema = z.union([
InitializeRequestSchema,
ListToolsRequestSchema,
CallToolRequestSchema,
])
type RequestSchema = z.infer<typeof RequestSchema>
export function createMcp(input: { tools: Tool[] }) {
return {
async process(message: JSONRPCRequest) {
try {
const parsed = RequestSchema.parse(message)
return await (async (): Promise<JSONRPCResponse> => {
if (parsed.method === "initialize")
return {
jsonrpc: "2.0",
id: message.id,
result: {
protocolVersion: "2024-11-05",
capabilities: {
tools: {},
},
serverInfo: {
name: "expose",
version: "0.0.1",
},
} as InitializeResult,
}
if (parsed.method === "tools/list") {
return {
jsonrpc: "2.0",
id: message.id,
result: {
tools: input.tools.map((tool) => ({
name: tool.name,
inputSchema: tool.args
? (zodToJsonSchema(tool.args as any, "args").definitions![
"args"
] as any)
: { type: "object" },
description: tool.description,
})),
} as ListToolsResult,
} satisfies JSONRPCResponse
}
if (parsed.method === "tools/call") {
const tool = input.tools.find(
(tool) => tool.name === parsed.params.name,
)
if (!tool)
throw new MCPError("Tool not found", ErrorCode.MethodNotFound)
let args = parsed.params.arguments
if (tool.args) {
const validated = await tool.args["~standard"].validate(args)
if (validated.issues)
throw new MCPError("Invalid arguments", ErrorCode.InvalidParams)
args = validated.value as any
}
return tool
.run(args)
.catch(
(error) =>
({
jsonrpc: "2.0",
id: message.id,
error: {
code: ErrorCode.InternalError,
message: error.message,
},
}) satisfies JSONRPCError,
)
.then(
(result) =>
({
jsonrpc: "2.0",
id: message.id,
result: {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
} as CallToolResult,
}) satisfies JSONRPCResponse,
)
}
throw new MCPError("Method not found", ErrorCode.MethodNotFound)
})()
} catch (error) {
if (error instanceof MCPError) {
const code = error.code
return {
jsonrpc: "2.0",
id: message.id,
error: { code, message: error.message },
} satisfies JSONRPCError
}
return {
jsonrpc: "2.0",
id: message.id,
error: {
code: ErrorCode.InternalError,
message: "Internal error",
},
} satisfies JSONRPCError
}
},
}
}
```
--------------------------------------------------------------------------------
/expose.go:
--------------------------------------------------------------------------------
```go
package expose
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/mark3labs/mcp-go/mcp"
)
type Client struct {
baseURL string
httpClient *http.Client
headers map[string]string
}
func NewClient(baseURL string, timeout time.Duration) *Client {
return &Client{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: timeout,
},
headers: map[string]string{
"Content-Type": "application/json",
},
}
}
func (c *Client) SetHeader(key, value string) {
c.headers[key] = value
}
func (c *Client) MakePostRequest(
ctx context.Context,
endpoint string,
body interface{},
) ([]byte, error) {
url := fmt.Sprintf("%s%s", c.baseURL, endpoint)
var requestBody []byte
var err error
if body != nil {
requestBody, err = json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(requestBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
for key, value := range c.headers {
req.Header.Set(key, value)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
responseBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
return responseBody, nil
}
func createErrorResponse(
id interface{},
code int,
message string,
) mcp.JSONRPCMessage {
return mcp.JSONRPCError{
JSONRPC: mcp.JSONRPC_VERSION,
ID: id,
Error: struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}{
Code: code,
Message: message,
},
}
}
func createResponse(id interface{}, result interface{}) mcp.JSONRPCMessage {
return mcp.JSONRPCResponse{
JSONRPC: mcp.JSONRPC_VERSION,
ID: id,
Result: result,
}
}
type ProxyStdioServer struct {
BaseURL string
Client *Client
errLogger *log.Logger
}
func NewProxyStdioServer(baseURL string, timeout time.Duration) *ProxyStdioServer {
return &ProxyStdioServer{
BaseURL: baseURL,
Client: NewClient(baseURL, timeout),
errLogger: log.New(os.Stderr, "", log.LstdFlags),
}
}
func (s *ProxyStdioServer) SetBaseURL(baseURL string) {
s.BaseURL = baseURL
}
func (s *ProxyStdioServer) SetErrLogger(errLogger *log.Logger) {
s.errLogger = errLogger
}
func (s *ProxyStdioServer) Listen(
ctx context.Context,
stdin io.Reader,
stdout io.Writer,
) error {
reader := bufio.NewReader(stdin)
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
// Use a goroutine to make the read cancellable
readChan := make(chan string, 1)
errChan := make(chan error, 1)
go func() {
line, err := reader.ReadString('\n')
if err != nil {
errChan <- err
return
}
readChan <- line
}()
select {
case <-ctx.Done():
return ctx.Err()
case err := <-errChan:
if err == io.EOF {
return nil
}
s.errLogger.Printf("Error reading input: %v", err)
return err
case line := <-readChan:
if err := s.proxyMessage(ctx, line, stdout); err != nil {
if err == io.EOF {
return nil
}
s.errLogger.Printf("Error handling message: %v", err)
return err
}
}
}
}
}
func (s *ProxyStdioServer) handleMessage(
ctx context.Context,
message json.RawMessage,
) mcp.JSONRPCMessage {
var baseMessage struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
ID interface{} `json:"id,omitempty"`
}
if err := json.Unmarshal(message, &baseMessage); err != nil {
return createErrorResponse(nil, mcp.PARSE_ERROR, "Failed to parse message")
}
// Check for valid JSONRPC version
if baseMessage.JSONRPC != mcp.JSONRPC_VERSION {
return createErrorResponse(
baseMessage.ID,
mcp.INVALID_REQUEST,
"Invalid JSON-RPC version",
)
}
if baseMessage.ID == nil {
return nil
}
if baseMessage.Method == "ping" {
var request mcp.PingRequest
if err := json.Unmarshal(message, &request); err != nil {
return createErrorResponse(
baseMessage.ID,
mcp.INVALID_REQUEST,
"Invalid ping request",
)
}
return createResponse(baseMessage.ID, mcp.EmptyResult{})
}
switch baseMessage.Method {
case "initialize", "tools/list", "tools/call":
response, err := s.Client.MakePostRequest(ctx, "/", message)
if err != nil {
return createErrorResponse(
baseMessage.ID,
mcp.INTERNAL_ERROR,
"Failed to make request",
)
}
var responseMessage mcp.JSONRPCMessage
if err := json.Unmarshal(response, &responseMessage); err != nil {
return createErrorResponse(
baseMessage.ID,
mcp.INTERNAL_ERROR,
"Failed to parse response",
)
}
return responseMessage
default:
return createErrorResponse(
baseMessage.ID,
mcp.METHOD_NOT_FOUND,
fmt.Sprintf("Method %s not supported", baseMessage.Method),
)
}
}
func (s *ProxyStdioServer) proxyMessage(
ctx context.Context,
line string,
writer io.Writer,
) error {
var rawMessage json.RawMessage
if err := json.Unmarshal([]byte(line), &rawMessage); err != nil {
response := createErrorResponse(nil, mcp.PARSE_ERROR, "Parse error")
return s.writeResponse(response, writer)
}
response := s.handleMessage(ctx, rawMessage)
if response != nil {
return s.writeResponse(response, writer)
}
return nil
}
// writeResponse marshals and writes a JSON-RPC response message followed by a newline.
// Returns an error if marshaling or writing fails.
func (s *ProxyStdioServer) writeResponse(
response mcp.JSONRPCMessage,
writer io.Writer,
) error {
responseBytes, err := json.Marshal(response)
if err != nil {
return err
}
// Write response followed by newline
if _, err := fmt.Fprintf(writer, "%s\n", responseBytes); err != nil {
return err
}
return nil
}
func ProxyServeStdio(baseURL string, timeout time.Duration) error {
ps := NewProxyStdioServer(baseURL, timeout)
ps.SetErrLogger(log.New(os.Stderr, "", log.LstdFlags))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
go func() {
<-sigChan
cancel()
}()
return ps.Listen(ctx, os.Stdin, os.Stdout)
}
```