# 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:
--------------------------------------------------------------------------------
```
1 | # dependencies (bun install)
2 | node_modules
3 |
4 | # output
5 | out
6 | dist
7 | *.tgz
8 |
9 | # code coverage
10 | coverage
11 | *.lcov
12 |
13 | # logs
14 | logs
15 | _.log
16 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
17 |
18 | # dotenv environment variable files
19 | .env
20 | .env.development.local
21 | .env.test.local
22 | .env.production.local
23 | .env.local
24 |
25 | # caches
26 | .eslintcache
27 | .cache
28 | *.tsbuildinfo
29 |
30 | # IntelliJ based IDEs
31 | .idea
32 |
33 | # Finder (MacOS) folder config
34 | .DS_Store
35 |
36 | tmp/
37 |
```
--------------------------------------------------------------------------------
/.changeset/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Changesets
2 |
3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4 | with multi-package repos, or single-package repos to help you version and publish your code. You can
5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6 |
7 | We have a quick list of common questions to get you started engaging with this project in
8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
9 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | ---
2 | title: Expose
3 | description: Easily build MCP tools for Claude desktop app
4 | ---
5 |
6 | Expose lets you build MCP tools that you can invoke with MCP client like Claude desktop app.
7 |
8 | - **Self-hosted**: You can easily self-host tools and deploy them on your own server.
9 | - **Unified gateway**: Generates a single HTTP endpoint that you can register with `expose-cli`
10 | - **Flexible**: Easily configure and customize your tools to fit your needs.
11 |
12 | ## Getting started
13 |
14 | 1. **Setup expose CLI**
15 |
16 | ```bash
17 | curl -fsSL https://github.com/a0dotrun/expose/releases/download/stable/download_cli.sh | bash
18 | ```
19 |
20 | 2. **Install dependencies**
21 |
22 | ```bash
23 | npm i @a0dotrun/expose
24 | ```
25 |
26 | 3. **Create server**
27 |
28 | ```bash
29 | touch src/server.ts
30 | ```
31 |
32 | ```ts title=src/server.ts
33 | import { create } from "@a0dotrun/expose"
34 |
35 | const app = create({
36 | tools: [],
37 | })
38 |
39 | export default {
40 | port: 3000,
41 | fetch: app.fetch,
42 | }
43 | ```
44 |
45 | 4. **Define your tools**
46 |
47 | ```diff lang=ts title=src/server.ts
48 | + import { tool } from "@a0dotrun/expose/tool"
49 | + import { subscription } from "@acme/lib/subscription"
50 | + import { z } from "zod"
51 |
52 | + const getCustomerSubscription = tool({
53 | + name: "getCustomerSubscription",
54 | + description: "Get subscription information for a customer",
55 | + args: z.object({
56 | + customer: z.string().uuid()
57 | + }),
58 | + run: async (input) => {
59 | + // Your subscription logic here
60 | + return input;
61 | + },
62 | + });
63 |
64 |
65 | + const createCustomerSubscription = tool({
66 | + name: "createCustomerSubscription",
67 | + description: "Create a subscription for a customer",
68 | + args: z.object({
69 | + customer: z.string().uuid()
70 | + }),
71 | + run: async (input) => {
72 | + // Your subscription logic here
73 | + return input;
74 | + },
75 | + });
76 |
77 | const app = create({
78 | tools: [
79 | + getCustomerSubscription,
80 | + createCustomerSubscription,
81 | ],
82 | });
83 | ```
84 |
85 | 5. **Start server**
86 |
87 | ```bash
88 | npm run dev
89 | ```
90 |
91 | You can also deploy the server and note down the public URL.
92 |
93 | 6. **Register in Claude desktop app**
94 | MACOS Claude desktop MCP config path: `~/Library/Application Support/Claude/claude_desktop_config.json`
95 |
96 | ```json
97 | {
98 | ...
99 | "mcpServers": {
100 | "subscriptionManager": {
101 | "command": "/Users/acmeuser/.local/bin/expose-cli",
102 | "args": ["--url", "http://localhost:3000", "--timeout", "15"]
103 | }
104 | }
105 | ...
106 | }
107 | ```
108 |
109 | _replace localhost with your public URL_
110 |
111 | ---
112 |
113 | expose is created by [@\_sanchitrk](https://x.com/_sanchitrk) at [a0](https://a0.run)
114 |
115 | ### Acknowledgements
116 |
117 | I was inspired by [opencontrol](https://github.com/toolbeam/opencontrol)
118 |
```
--------------------------------------------------------------------------------
/bunfig.toml:
--------------------------------------------------------------------------------
```toml
1 | [install]
2 | exact = true
```
--------------------------------------------------------------------------------
/.changeset/thirty-melons-jog.md:
--------------------------------------------------------------------------------
```markdown
1 | ---
2 | "@a0dotrun/expose": patch
3 | ---
4 |
5 | tweaks
6 |
```
--------------------------------------------------------------------------------
/.changeset/full-parents-draw.md:
--------------------------------------------------------------------------------
```markdown
1 | ---
2 | "@a0dotrun/expose": patch
3 | ---
4 |
5 | updated install script
6 |
```
--------------------------------------------------------------------------------
/.changeset/honest-bobcats-speak.md:
--------------------------------------------------------------------------------
```markdown
1 | ---
2 | "@a0dotrun/expose": patch
3 | ---
4 |
5 | fixes install expose-cli script
6 |
```
--------------------------------------------------------------------------------
/examples/echo/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "@expose/example-echo",
3 | "private": true,
4 | "version": "0.0.0",
5 | "dependencies": {
6 | "@a0dotrun/expose": "workspace:*"
7 | }
8 | }
9 |
```
--------------------------------------------------------------------------------
/examples/systeminfo/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "@expose/example-systeminfo",
3 | "private": true,
4 | "version": "0.0.0",
5 | "dependencies": {
6 | "@a0dotrun/expose": "workspace:*"
7 | }
8 | }
9 |
```
--------------------------------------------------------------------------------
/.changeset/commit.cjs:
--------------------------------------------------------------------------------
```
1 | /** @type {import('@changesets/types').CommitFunctions["getAddMessage"]} */
2 | module.exports.getAddMessage = async (changeset) => {
3 | return changeset.summary;
4 | };
5 |
```
--------------------------------------------------------------------------------
/examples/airtabl3/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "@expose/example-airtabl3",
3 | "private": true,
4 | "version": "0.0.0",
5 | "dependencies": {
6 | "@a0dotrun/expose": "workspace:*",
7 | "airtable": "0.12.2"
8 | }
9 | }
10 |
```
--------------------------------------------------------------------------------
/packages/expose/tsconfig.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "extends": "@tsconfig/node22/tsconfig.json",
4 | "compilerOptions": {
5 | "outDir": "dist",
6 | "declaration": true
7 | }
8 | }
9 |
```
--------------------------------------------------------------------------------
/packages/expose/CHANGELOG.md:
--------------------------------------------------------------------------------
```markdown
1 | # expose
2 |
3 | ## 0.0.4
4 |
5 | ### Patch Changes
6 |
7 | - 3a6f233: gh tweaks
8 |
9 | ## 0.0.3
10 |
11 | ### Patch Changes
12 |
13 | - a8abced: fix package naming
14 |
15 | ## 0.0.2
16 |
17 | ### Patch Changes
18 |
19 | - 35fe442: v0 working example with examples
20 |
```
--------------------------------------------------------------------------------
/.changeset/config.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "$schema": "https://unpkg.com/@changesets/[email protected]/schema.json",
3 | "changelog": "@changesets/cli/changelog",
4 | "commit": "./commit.cjs",
5 | "fixed": [["@a0dotrun/expose"]],
6 | "linked": [],
7 | "access": "public",
8 | "baseBranch": "main",
9 | "updateInternalDependencies": "patch",
10 | "ignore": ["@expose/example-*"]
11 | }
12 |
```
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "name": "expose",
3 | "private": true,
4 | "type": "module",
5 | "packageManager": "bun",
6 | "workspaces": [
7 | "packages/*",
8 | "examples/*"
9 | ],
10 | "devDependencies": {
11 | "@changesets/cli": "2.28.1",
12 | "@tsconfig/node22": "22.0.0",
13 | "@types/node": "22.13.9",
14 | "prettier": "3.5.3",
15 | "typescript": "^5"
16 | },
17 | "prettier": {
18 | "semi": false
19 | }
20 | }
21 |
```
--------------------------------------------------------------------------------
/examples/systeminfo/lib/info.ts:
--------------------------------------------------------------------------------
```typescript
1 | import os from "os"
2 |
3 | export function serverInfo() {
4 | return {
5 | hostname: os.hostname(),
6 | platform: os.platform(),
7 | arch: os.arch(),
8 | uptime: os.uptime(),
9 | totalMemory: os.totalmem(),
10 | freeMemory: os.freemem(),
11 | cpus: os.cpus().map((cpu) => ({
12 | model: cpu.model,
13 | speed: cpu.speed,
14 | })),
15 | networkInterfaces: os.networkInterfaces(),
16 | loadAvg: os.loadavg(),
17 | }
18 | }
19 |
```
--------------------------------------------------------------------------------
/examples/echo/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { create } from "@a0dotrun/expose"
2 | import { tool } from "@a0dotrun/expose/tool"
3 | import { z } from "zod"
4 |
5 | const echo = tool({
6 | name: "echo",
7 | description: "echoes the message back",
8 | args: z.object({
9 | message: z.string(),
10 | }),
11 | async run(args) {
12 | return "Echo from server: " + args.message
13 | },
14 | })
15 |
16 | const app = create({
17 | tools: [echo],
18 | })
19 |
20 | export default {
21 | port: 3000,
22 | fetch: app.fetch,
23 | }
24 |
```
--------------------------------------------------------------------------------
/examples/systeminfo/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { create } from "@a0dotrun/expose"
2 | import { tool } from "@a0dotrun/expose/tool"
3 | import { serverInfo } from "./lib/info"
4 |
5 | const systemInfo = tool({
6 | name: "systemInfo",
7 | description:
8 | "Get sytem information about the server like uptime, memory, cpu, etc.",
9 | run: async () => {
10 | return serverInfo()
11 | },
12 | })
13 |
14 | const app = create({
15 | tools: [systemInfo],
16 | })
17 |
18 | export default {
19 | port: 3000,
20 | fetch: app.fetch,
21 | }
22 |
```
--------------------------------------------------------------------------------
/.github/workflows/format.yml:
--------------------------------------------------------------------------------
```yaml
1 | name: Format and Lint
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | workflow_dispatch:
8 |
9 | jobs:
10 | format:
11 | runs-on: ubuntu-latest
12 |
13 | permissions:
14 | contents: write
15 | pull-requests: write
16 |
17 | steps:
18 | - uses: actions/checkout@v4
19 | with:
20 | ref: ${{ github.head_ref }}
21 | fetch-depth: 0
22 | - uses: oven-sh/setup-bun@v2
23 | - run: |
24 | git config --local user.email "[email protected]"
25 | git config --local user.name "gh-actions-bot"
26 | make format
27 |
```
--------------------------------------------------------------------------------
/packages/expose/package.json:
--------------------------------------------------------------------------------
```json
1 | {
2 | "$schema": "https://json.schemastore.org/package.json",
3 | "name": "@a0dotrun/expose",
4 | "version": "0.0.4",
5 | "type": "module",
6 | "scripts": {
7 | "build": "bun tsc"
8 | },
9 | "exports": {
10 | ".": "./dist/index.js",
11 | "./*": "./dist/*.js"
12 | },
13 | "dependencies": {
14 | "@modelcontextprotocol/sdk": "1.6.1",
15 | "@tsconfig/bun": "1.0.7",
16 | "hono": "4.7.4",
17 | "zod": "3.24.2",
18 | "zod-to-json-schema": "3.24.3"
19 | },
20 | "devDependencies": {
21 | "@standard-schema/spec": "1.0.0"
22 | },
23 | "publishConfig": {
24 | "access": "public"
25 | }
26 | }
27 |
```
--------------------------------------------------------------------------------
/packages/expose/src/tool.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { StandardSchemaV1 } from "@standard-schema/spec"
2 | import { z } from "zod"
3 |
4 | export interface Tool<
5 | Args extends undefined | StandardSchemaV1 = undefined | StandardSchemaV1,
6 | > {
7 | name: string
8 | description: string
9 | args?: Args
10 | run: Args extends StandardSchemaV1
11 | ? (args: StandardSchemaV1.InferOutput<Args>) => Promise<any>
12 | : () => Promise<any>
13 | }
14 |
15 | export function tool<Args extends undefined | StandardSchemaV1>(
16 | input: Tool<Args>,
17 | ) {
18 | return input
19 | }
20 |
21 | // Example
22 |
23 | tool({
24 | name: "echo",
25 | description: "echoes the message back",
26 | args: z.object({
27 | message: z.string(),
28 | }),
29 | async run(args) {
30 | return args.message
31 | },
32 | })
33 |
```
--------------------------------------------------------------------------------
/packages/expose/src/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { Hono } from "hono"
2 | // import { HTTPException } from "hono/http-exception"
3 | import { Tool } from "./tool.js"
4 | import { createMcp } from "./mcp.js"
5 |
6 | export interface ExposeOptions {
7 | token?: string
8 | }
9 |
10 | export function create(input: { tools: Tool[]; key?: string }) {
11 | const mcp = createMcp({ tools: input.tools })
12 |
13 | return (
14 | new Hono()
15 | // .use((c, next) => {
16 | // if (input?.key) {
17 | // const authorization = c.req.header("Authorization")
18 | // if (authorization !== `Bearer ${input?.key}`) {
19 | // throw new HTTPException(401)
20 | // }
21 | // }
22 | // return next()
23 | // })
24 | .post("/", async (c) => {
25 | const body = await c.req.json()
26 | console.log("mcp request", body)
27 | const result = await mcp.process(body)
28 | console.log("mcp response", result)
29 | return c.json(result)
30 | })
31 | )
32 | }
33 |
```
--------------------------------------------------------------------------------
/cmd/cli.go:
--------------------------------------------------------------------------------
```go
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "log"
6 | "os"
7 | "time"
8 |
9 | "github.com/a0dotrun/expose"
10 | "github.com/spf13/cobra"
11 | )
12 |
13 | func main() {
14 | var url string
15 | var timeout int
16 |
17 | rootCmd := &cobra.Command{
18 | Use: "expose-cli",
19 | Short: "A CLI tool for proxying MCP tools",
20 | Long: `expose-cli is a command-line tool that proxies MCP tools to a specified URL with configurations`,
21 | Run: func(cmd *cobra.Command, args []string) {
22 | if url == "" {
23 | fmt.Println("Error: URL is required")
24 | cmd.Help()
25 | os.Exit(1)
26 | }
27 |
28 | timeoutDuration := time.Duration(timeout) * time.Second
29 | if err := expose.ProxyServeStdio(url, timeoutDuration); err != nil {
30 | log.Fatalf("Failed to start proxy server: %v", err)
31 | }
32 | },
33 | }
34 |
35 | // Define flags
36 | rootCmd.Flags().StringVar(&url, "url", "", "Target URL to proxy (required)")
37 | rootCmd.Flags().IntVar(&timeout, "timeout", 10, "Connection timeout in seconds")
38 |
39 | // Execute
40 | if err := rootCmd.Execute(); err != nil {
41 | fmt.Println(err)
42 | os.Exit(1)
43 | }
44 | }
45 |
```
--------------------------------------------------------------------------------
/examples/airtabl3/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import { create } from "@a0dotrun/expose"
2 | import { tool } from "@a0dotrun/expose/tool"
3 | import { z } from "zod"
4 | import { createRecord, fetchRecords, updateRecord } from "./lib/index"
5 |
6 | const StageSchema = z.enum(["Prospect", "Qualified", "Closed"])
7 |
8 | const createCRMRecord = tool({
9 | name: "create_sales_crm_record",
10 | description: "Creates a new record in Sales CRM and generates a unique ID",
11 | args: z.object({
12 | email: z.string().email(),
13 | company: z.string(),
14 | stage: StageSchema,
15 | contact: z.string(),
16 | notes: z.string(),
17 | }),
18 | async run(args) {
19 | const { data, error } = await createRecord({
20 | Email: args.email,
21 | Company: args.company,
22 | Stage: args.stage,
23 | Contact: args.contact,
24 | Notes: args.notes,
25 | })
26 | if (error) {
27 | throw new Error(error.message)
28 | }
29 | return data
30 | },
31 | })
32 |
33 | const listCRMRecords = tool({
34 | name: "list_sales_crm_records",
35 | description: "Lists all sales CRM records",
36 | async run() {
37 | const { data, error } = await fetchRecords()
38 | if (error) {
39 | throw new Error(error.message)
40 | }
41 | return data
42 | },
43 | })
44 |
45 | const updateCRMRecord = tool({
46 | name: "update_sales_crm_record",
47 | description: "Updates an existing record in Sales CRM",
48 | args: z.object({
49 | id: z.string(),
50 | email: z.string().email().optional(),
51 | company: z.string().optional(),
52 | stage: StageSchema.optional(),
53 | contact: z.string().optional(),
54 | notes: z.string().optional(),
55 | }),
56 | async run(args) {
57 | const { id, ...others } = args
58 | const { data, error } = await updateRecord(id, {
59 | Email: others.email,
60 | Company: others.company,
61 | Stage: others.stage,
62 | Contact: others.contact,
63 | Notes: others.notes,
64 | })
65 | if (error) {
66 | throw new Error(error.message)
67 | }
68 | return data
69 | },
70 | })
71 |
72 | const app = create({
73 | tools: [createCRMRecord, listCRMRecords, updateCRMRecord],
74 | })
75 |
76 | export default {
77 | port: 3000,
78 | fetch: app.fetch,
79 | }
80 |
```
--------------------------------------------------------------------------------
/examples/airtabl3/lib/index.ts:
--------------------------------------------------------------------------------
```typescript
1 | import Airtable from "airtable"
2 |
3 | const base = new Airtable({
4 | apiKey: `...`,
5 | }).base("...")
6 |
7 | interface NewRecord {
8 | Email: string
9 | Company: string
10 | Stage: string
11 | Contact: string
12 | Notes: string
13 | }
14 |
15 | interface UpdateRecord {
16 | Email?: string
17 | Company?: string
18 | Stage?: string
19 | Contact?: string
20 | Notes?: string
21 | }
22 |
23 | export async function fetchRecords() {
24 | try {
25 | const records = await base("Default").select({ view: "Grid view" }).all()
26 | const results = records.map((record) => ({
27 | id: record.id,
28 | company: record.fields.Company,
29 | stage: record.fields.Stage,
30 | contact: record.fields.Contact,
31 | notes: record.fields.Notes,
32 | email: record.fields.Email,
33 | }))
34 | return {
35 | data: results,
36 | error: null,
37 | }
38 | } catch (err) {
39 | console.error(err)
40 | return {
41 | data: null,
42 | error: err,
43 | }
44 | }
45 | }
46 |
47 | export async function createRecord(args: NewRecord) {
48 | try {
49 | const record = await base("Default").create({
50 | Email: args.Email,
51 | Company: args.Company,
52 | Stage: args.Stage,
53 | Contact: args.Contact,
54 | Notes: args.Notes,
55 | })
56 | return {
57 | data: record.id,
58 | error: null,
59 | }
60 | } catch (err) {
61 | console.error(err)
62 | return {
63 | data: null,
64 | error: err,
65 | }
66 | }
67 | }
68 |
69 | export async function updateRecord(id: string, args: UpdateRecord) {
70 | try {
71 | const record = await base("Default").update(id, {
72 | Email: args.Email || undefined,
73 | Company: args.Company || undefined,
74 | Stage: args.Stage || undefined,
75 | Contact: args.Contact || undefined,
76 | Notes: args.Notes || undefined,
77 | })
78 | return {
79 | data: record.id,
80 | error: null,
81 | }
82 | } catch (err) {
83 | console.error(err)
84 | return {
85 | data: null,
86 | error: err,
87 | }
88 | }
89 | }
90 |
91 | // createRecord({
92 | // Email: "[email protected]",
93 | // Company: "Aravind Srinivasan",
94 | // Stage: "Prospect",
95 | // Contact: "Aravind Srinivasan",
96 | // Notes: "AI search engine",
97 | // })
98 |
99 | // ;(async () => {
100 | // const results = await fetchRecords()
101 | // console.log(results)
102 | // })()
103 |
104 | // updateRecord("rec9FCaEJEcis2fub", { Contact: "Amit Singh" })
105 |
```
--------------------------------------------------------------------------------
/download_cli.sh:
--------------------------------------------------------------------------------
```bash
1 | #!/usr/bin/env bash
2 | set -eu
3 |
4 | ##############################################################################
5 | # Expose CLI Install Script
6 | #
7 | # This script downloads the latest stable 'expose-cli' binary from GitHub releases
8 | # and installs it to your system.
9 | #
10 | # Supported OS: macOS (darwin), Linux
11 | # Supported Architectures: x86_64, arm64
12 | #
13 | # Usage:
14 | # curl -fsSL https://github.com/a0dotrun/expose/releases/download/stable/install.sh | bash
15 | #
16 | ##############################################################################
17 |
18 | # --- 1) Check for curl ---
19 | if ! command -v curl >/dev/null 2>&1; then
20 | echo "Error: 'curl' is required to download Expose CLI. Please install curl and try again."
21 | exit 1
22 | fi
23 |
24 | # --- 2) Detect OS/Architecture ---
25 | OS=$(uname -s | tr '[:upper:]' '[:lower:]')
26 | ARCH=$(uname -m)
27 |
28 | case "$OS" in
29 | linux) OS_ID="unknown-linux-gnu" ;;
30 | darwin) OS_ID="apple-darwin" ;;
31 | *)
32 | echo "Error: Unsupported OS '$OS'. Expose CLI only supports Linux and macOS."
33 | exit 1
34 | ;;
35 | esac
36 |
37 | case "$ARCH" in
38 | x86_64|amd64)
39 | ARCH_ID="x86_64"
40 | ;;
41 | arm64|aarch64)
42 | ARCH_ID="aarch64"
43 | ;;
44 | *)
45 | echo "Error: Unsupported architecture '$ARCH'. Expose CLI supports x86_64 and arm64."
46 | exit 1
47 | ;;
48 | esac
49 |
50 | # --- 3) Set download URL ---
51 | REPO="a0dotrun/expose"
52 | INSTALL_DIR="${EXPOSE_BIN_DIR:-"$HOME/.local/bin"}"
53 | FILE="expose-cli-${ARCH_ID}-${OS_ID}.tar.bz2"
54 | DOWNLOAD_URL="https://github.com/$REPO/releases/download/stable/$FILE"
55 |
56 | # --- 4) Download the binary ---
57 | echo "Downloading $FILE from $DOWNLOAD_URL..."
58 | curl -sLf "$DOWNLOAD_URL" --output "$FILE"
59 |
60 | # --- 5) Extract & Install ---
61 | mkdir -p "$INSTALL_DIR"
62 | tar -xjf "$FILE" -C "$INSTALL_DIR"
63 | rm "$FILE"
64 |
65 | # Ensure it's executable
66 | chmod +x "$INSTALL_DIR/expose-cli"
67 |
68 | echo "✅ Expose CLI installed successfully at $INSTALL_DIR/expose-cli"
69 |
70 | # --- 6) Add to PATH if needed ---
71 | if [[ ":$PATH:" != *":$INSTALL_DIR:"* ]]; then
72 | echo ""
73 | echo "Warning: Expose CLI installed, but $INSTALL_DIR is not in your PATH."
74 | echo "Add it by running:"
75 | echo " export PATH=\"$INSTALL_DIR:\$PATH\""
76 | echo "Then reload your shell (e.g. 'source ~/.bashrc', 'source ~/.zshrc')."
77 | fi
78 |
79 | echo "Run 'expose-cli --help' to get started!"
80 |
```
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
```yaml
1 | # This workflow is main release, needs to be manually tagged & pushed.
2 | on:
3 | push:
4 | paths-ignore:
5 | - "docs/**"
6 | tags:
7 | - "v*"
8 |
9 | name: Release
10 | concurrency:
11 | group: ${{ github.workflow }}-${{ github.ref }}
12 | cancel-in-progress: true
13 |
14 | jobs:
15 | # ------------------------------------
16 | # 1) Build CLI for multiple OS/Arch (Linux, MacOS)
17 | # ------------------------------------
18 | build-cli:
19 | uses: ./.github/workflows/build-cli.yml
20 |
21 | # ------------------------------------
22 | # 2) Upload Install CLI Script
23 | # ------------------------------------
24 | install-script:
25 | name: Upload Install Script
26 | runs-on: ubuntu-latest
27 | needs: [build-cli]
28 | steps:
29 | - uses: actions/checkout@v4
30 | - uses: actions/upload-artifact@v4
31 | with:
32 | name: download_cli.sh
33 | path: download_cli.sh
34 |
35 | # ------------------------------------
36 | # 3) Create/Update GitHub Release CLI
37 | # ------------------------------------
38 | release-cli:
39 | name: Release CLI
40 | runs-on: ubuntu-latest
41 | needs: [build-cli, install-script]
42 | permissions:
43 | contents: write
44 | steps:
45 | - name: Download all artifacts
46 | uses: actions/download-artifact@v4
47 | with:
48 | merge-multiple: true
49 |
50 | - name: Release versioned
51 | uses: ncipollo/release-action@v1
52 | with:
53 | token: ${{ secrets.GITHUB_TOKEN }}
54 | artifacts: |
55 | expose-cli-*.tar.bz2
56 | download_cli.sh
57 | allowUpdates: true
58 | omitBody: true
59 | omitPrereleaseDuringUpdate: true
60 |
61 | - name: Release CLI Stable
62 | uses: ncipollo/release-action@v1
63 | with:
64 | tag: stable
65 | name: Stable
66 | token: ${{ secrets.GITHUB_TOKEN }}
67 | artifacts: |
68 | expose-cli-*.tar.bz2
69 | download_cli.sh
70 | allowUpdates: true
71 | omitBody: true
72 | omitPrereleaseDuringUpdate: true
73 | # ------------------------------------
74 | # 4) Publish NPM Package
75 | # ------------------------------------
76 | publish-npm:
77 | name: Publish NPM Package
78 | runs-on: ubuntu-latest
79 | steps:
80 | - name: Checkout repository
81 | uses: actions/checkout@v3
82 |
83 | - name: Setup Bun
84 | uses: oven-sh/setup-bun@v2
85 |
86 | - name: Install dependencies
87 | run: bun install
88 |
89 | - name: Authenticate NPM
90 | run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
91 |
92 | - name: Run publish
93 | run: make publish
94 | env:
95 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
96 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
97 |
```
--------------------------------------------------------------------------------
/.github/workflows/build-cli.yml:
--------------------------------------------------------------------------------
```yaml
1 | # This is a **reuseable** workflow that bundles the CLI
2 | # It doesn't get triggered on its own. It gets used in multiple workflows:
3 | # - release.yml
4 | on:
5 | workflow_call:
6 | inputs:
7 | version:
8 | required: false
9 | default: ""
10 | type: string
11 | # Let's allow overriding the OSes and architectures in JSON array form:
12 | # e.g. '["ubuntu-latest","macos-latest"]'
13 | # If no input is provided, these defaults apply.
14 | operating-systems:
15 | type: string
16 | required: false
17 | default: '["ubuntu-latest","macos-latest"]'
18 | architectures:
19 | type: string
20 | required: false
21 | default: '["x86_64","aarch64"]'
22 |
23 | name: "Reusable workflow to build CLI"
24 |
25 | jobs:
26 | build-cli:
27 | name: Build CLI
28 | runs-on: ${{ matrix.os }}
29 | strategy:
30 | fail-fast: false
31 | matrix:
32 | os: ${{ fromJson(inputs.operating-systems) }}
33 | architecture: ${{ fromJson(inputs.architectures) }}
34 | include:
35 | - os: ubuntu-latest
36 | target-suffix: unknown-linux-gnu
37 | goos: linux
38 | os_name: linux
39 | - os: macos-latest
40 | target-suffix: apple-darwin
41 | goos: darwin
42 | os_name: macos
43 |
44 | steps:
45 | - name: Checkout code
46 | uses: actions/checkout@v4
47 |
48 | - name: Set up Go
49 | uses: actions/setup-go@v5
50 | with:
51 | go-version: stable
52 |
53 | - name: Install jq
54 | run: |
55 | if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then
56 | sudo apt-get update && sudo apt-get install -y jq
57 | elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then
58 | brew install jq
59 | fi
60 |
61 | - name: Extract version
62 | run: |
63 | VERSION=$(jq -r '.version' packages/expose/package.json)
64 | echo "VERSION=$VERSION" >> $GITHUB_ENV
65 | echo "Extracted version: $VERSION"
66 |
67 | - name: Set GOARCH
68 | run: |
69 | if [[ "${{ matrix.architecture }}" == "x86_64" ]]; then
70 | echo "GOARCH=amd64" >> $GITHUB_ENV
71 | elif [[ "${{ matrix.architecture }}" == "aarch64" ]]; then
72 | echo "GOARCH=arm64" >> $GITHUB_ENV
73 | fi
74 |
75 | - name: Build binary
76 | env:
77 | GOOS: ${{ matrix.goos }}
78 | GOARCH: ${{ env.GOARCH }}
79 | run: |
80 | TARGET="${{ matrix.architecture }}-${{ matrix.target-suffix }}"
81 | echo "Building for target: ${TARGET} (GOOS=$GOOS, GOARCH=$GOARCH)"
82 |
83 | # Create output directory
84 | mkdir -p build/${TARGET}/release
85 |
86 | # Build the binary
87 | go build -o build/${TARGET}/release/expose-cli cmd/cli.go
88 |
89 | # Create tarball
90 | cd build/${TARGET}/release
91 | tar -cjf expose-cli-${TARGET}.tar.bz2 expose-cli
92 | cd -
93 |
94 | # Set artifact path for upload step
95 | echo "ARTIFACT=build/${TARGET}/release/expose-cli-${TARGET}.tar.bz2" >> $GITHUB_ENV
96 |
97 | echo "Build complete: ${{ env.ARTIFACT }}"
98 |
99 | - name: Upload artifact
100 | uses: actions/upload-artifact@v4
101 | with:
102 | name: expose-cli-${{ matrix.architecture }}-${{ matrix.target-suffix }}
103 | path: ${{ env.ARTIFACT }}
104 |
```
--------------------------------------------------------------------------------
/packages/expose/src/mcp.ts:
--------------------------------------------------------------------------------
```typescript
1 | import {
2 | CallToolRequestSchema,
3 | CallToolResult,
4 | ErrorCode,
5 | InitializeRequestSchema,
6 | InitializeResult,
7 | JSONRPCError,
8 | JSONRPCRequest,
9 | JSONRPCResponse,
10 | ListToolsRequestSchema,
11 | ListToolsResult,
12 | } from "@modelcontextprotocol/sdk/types.js"
13 | import { z } from "zod"
14 | import { zodToJsonSchema } from "zod-to-json-schema"
15 | import { Tool } from "./tool.js"
16 |
17 | class MCPError extends Error {
18 | constructor(
19 | message: string,
20 | public code: ErrorCode,
21 | ) {
22 | super(message)
23 | }
24 | }
25 |
26 | const RequestSchema = z.union([
27 | InitializeRequestSchema,
28 | ListToolsRequestSchema,
29 | CallToolRequestSchema,
30 | ])
31 |
32 | type RequestSchema = z.infer<typeof RequestSchema>
33 |
34 | export function createMcp(input: { tools: Tool[] }) {
35 | return {
36 | async process(message: JSONRPCRequest) {
37 | try {
38 | const parsed = RequestSchema.parse(message)
39 | return await (async (): Promise<JSONRPCResponse> => {
40 | if (parsed.method === "initialize")
41 | return {
42 | jsonrpc: "2.0",
43 | id: message.id,
44 | result: {
45 | protocolVersion: "2024-11-05",
46 | capabilities: {
47 | tools: {},
48 | },
49 | serverInfo: {
50 | name: "expose",
51 | version: "0.0.1",
52 | },
53 | } as InitializeResult,
54 | }
55 |
56 | if (parsed.method === "tools/list") {
57 | return {
58 | jsonrpc: "2.0",
59 | id: message.id,
60 | result: {
61 | tools: input.tools.map((tool) => ({
62 | name: tool.name,
63 | inputSchema: tool.args
64 | ? (zodToJsonSchema(tool.args as any, "args").definitions![
65 | "args"
66 | ] as any)
67 | : { type: "object" },
68 | description: tool.description,
69 | })),
70 | } as ListToolsResult,
71 | } satisfies JSONRPCResponse
72 | }
73 |
74 | if (parsed.method === "tools/call") {
75 | const tool = input.tools.find(
76 | (tool) => tool.name === parsed.params.name,
77 | )
78 | if (!tool)
79 | throw new MCPError("Tool not found", ErrorCode.MethodNotFound)
80 |
81 | let args = parsed.params.arguments
82 | if (tool.args) {
83 | const validated = await tool.args["~standard"].validate(args)
84 | if (validated.issues)
85 | throw new MCPError("Invalid arguments", ErrorCode.InvalidParams)
86 | args = validated.value as any
87 | }
88 |
89 | return tool
90 | .run(args)
91 | .catch(
92 | (error) =>
93 | ({
94 | jsonrpc: "2.0",
95 | id: message.id,
96 | error: {
97 | code: ErrorCode.InternalError,
98 | message: error.message,
99 | },
100 | }) satisfies JSONRPCError,
101 | )
102 | .then(
103 | (result) =>
104 | ({
105 | jsonrpc: "2.0",
106 | id: message.id,
107 | result: {
108 | content: [
109 | {
110 | type: "text",
111 | text: JSON.stringify(result, null, 2),
112 | },
113 | ],
114 | } as CallToolResult,
115 | }) satisfies JSONRPCResponse,
116 | )
117 | }
118 | throw new MCPError("Method not found", ErrorCode.MethodNotFound)
119 | })()
120 | } catch (error) {
121 | if (error instanceof MCPError) {
122 | const code = error.code
123 | return {
124 | jsonrpc: "2.0",
125 | id: message.id,
126 | error: { code, message: error.message },
127 | } satisfies JSONRPCError
128 | }
129 | return {
130 | jsonrpc: "2.0",
131 | id: message.id,
132 | error: {
133 | code: ErrorCode.InternalError,
134 | message: "Internal error",
135 | },
136 | } satisfies JSONRPCError
137 | }
138 | },
139 | }
140 | }
141 |
```
--------------------------------------------------------------------------------
/expose.go:
--------------------------------------------------------------------------------
```go
1 | package expose
2 |
3 | import (
4 | "bufio"
5 | "bytes"
6 | "context"
7 | "encoding/json"
8 | "fmt"
9 | "io"
10 | "log"
11 | "net/http"
12 | "os"
13 | "os/signal"
14 | "syscall"
15 | "time"
16 |
17 | "github.com/mark3labs/mcp-go/mcp"
18 | )
19 |
20 | type Client struct {
21 | baseURL string
22 | httpClient *http.Client
23 | headers map[string]string
24 | }
25 |
26 | func NewClient(baseURL string, timeout time.Duration) *Client {
27 | return &Client{
28 | baseURL: baseURL,
29 | httpClient: &http.Client{
30 | Timeout: timeout,
31 | },
32 | headers: map[string]string{
33 | "Content-Type": "application/json",
34 | },
35 | }
36 | }
37 |
38 | func (c *Client) SetHeader(key, value string) {
39 | c.headers[key] = value
40 | }
41 |
42 | func (c *Client) MakePostRequest(
43 | ctx context.Context,
44 | endpoint string,
45 | body interface{},
46 | ) ([]byte, error) {
47 | url := fmt.Sprintf("%s%s", c.baseURL, endpoint)
48 | var requestBody []byte
49 | var err error
50 | if body != nil {
51 | requestBody, err = json.Marshal(body)
52 | if err != nil {
53 | return nil, fmt.Errorf("failed to marshal request body: %w", err)
54 | }
55 | }
56 |
57 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBuffer(requestBody))
58 | if err != nil {
59 | return nil, fmt.Errorf("failed to create request: %w", err)
60 | }
61 |
62 | for key, value := range c.headers {
63 | req.Header.Set(key, value)
64 | }
65 |
66 | resp, err := c.httpClient.Do(req)
67 | if err != nil {
68 | return nil, fmt.Errorf("failed to send request: %w", err)
69 | }
70 | defer resp.Body.Close()
71 |
72 | responseBody, err := io.ReadAll(resp.Body)
73 | if err != nil {
74 | return nil, fmt.Errorf("failed to read response body: %w", err)
75 | }
76 |
77 | return responseBody, nil
78 | }
79 |
80 | func createErrorResponse(
81 | id interface{},
82 | code int,
83 | message string,
84 | ) mcp.JSONRPCMessage {
85 | return mcp.JSONRPCError{
86 | JSONRPC: mcp.JSONRPC_VERSION,
87 | ID: id,
88 | Error: struct {
89 | Code int `json:"code"`
90 | Message string `json:"message"`
91 | Data interface{} `json:"data,omitempty"`
92 | }{
93 | Code: code,
94 | Message: message,
95 | },
96 | }
97 | }
98 |
99 | func createResponse(id interface{}, result interface{}) mcp.JSONRPCMessage {
100 | return mcp.JSONRPCResponse{
101 | JSONRPC: mcp.JSONRPC_VERSION,
102 | ID: id,
103 | Result: result,
104 | }
105 | }
106 |
107 | type ProxyStdioServer struct {
108 | BaseURL string
109 | Client *Client
110 | errLogger *log.Logger
111 | }
112 |
113 | func NewProxyStdioServer(baseURL string, timeout time.Duration) *ProxyStdioServer {
114 | return &ProxyStdioServer{
115 | BaseURL: baseURL,
116 | Client: NewClient(baseURL, timeout),
117 | errLogger: log.New(os.Stderr, "", log.LstdFlags),
118 | }
119 | }
120 |
121 | func (s *ProxyStdioServer) SetBaseURL(baseURL string) {
122 | s.BaseURL = baseURL
123 | }
124 |
125 | func (s *ProxyStdioServer) SetErrLogger(errLogger *log.Logger) {
126 | s.errLogger = errLogger
127 | }
128 |
129 | func (s *ProxyStdioServer) Listen(
130 | ctx context.Context,
131 | stdin io.Reader,
132 | stdout io.Writer,
133 | ) error {
134 |
135 | reader := bufio.NewReader(stdin)
136 |
137 | for {
138 | select {
139 | case <-ctx.Done():
140 | return ctx.Err()
141 | default:
142 | // Use a goroutine to make the read cancellable
143 | readChan := make(chan string, 1)
144 | errChan := make(chan error, 1)
145 |
146 | go func() {
147 | line, err := reader.ReadString('\n')
148 | if err != nil {
149 | errChan <- err
150 | return
151 | }
152 | readChan <- line
153 | }()
154 |
155 | select {
156 | case <-ctx.Done():
157 | return ctx.Err()
158 | case err := <-errChan:
159 | if err == io.EOF {
160 | return nil
161 | }
162 | s.errLogger.Printf("Error reading input: %v", err)
163 | return err
164 | case line := <-readChan:
165 | if err := s.proxyMessage(ctx, line, stdout); err != nil {
166 | if err == io.EOF {
167 | return nil
168 | }
169 | s.errLogger.Printf("Error handling message: %v", err)
170 | return err
171 | }
172 | }
173 | }
174 | }
175 | }
176 |
177 | func (s *ProxyStdioServer) handleMessage(
178 | ctx context.Context,
179 | message json.RawMessage,
180 | ) mcp.JSONRPCMessage {
181 | var baseMessage struct {
182 | JSONRPC string `json:"jsonrpc"`
183 | Method string `json:"method"`
184 | ID interface{} `json:"id,omitempty"`
185 | }
186 |
187 | if err := json.Unmarshal(message, &baseMessage); err != nil {
188 | return createErrorResponse(nil, mcp.PARSE_ERROR, "Failed to parse message")
189 | }
190 |
191 | // Check for valid JSONRPC version
192 | if baseMessage.JSONRPC != mcp.JSONRPC_VERSION {
193 | return createErrorResponse(
194 | baseMessage.ID,
195 | mcp.INVALID_REQUEST,
196 | "Invalid JSON-RPC version",
197 | )
198 | }
199 |
200 | if baseMessage.ID == nil {
201 | return nil
202 | }
203 |
204 | if baseMessage.Method == "ping" {
205 | var request mcp.PingRequest
206 | if err := json.Unmarshal(message, &request); err != nil {
207 | return createErrorResponse(
208 | baseMessage.ID,
209 | mcp.INVALID_REQUEST,
210 | "Invalid ping request",
211 | )
212 | }
213 | return createResponse(baseMessage.ID, mcp.EmptyResult{})
214 | }
215 |
216 | switch baseMessage.Method {
217 | case "initialize", "tools/list", "tools/call":
218 | response, err := s.Client.MakePostRequest(ctx, "/", message)
219 | if err != nil {
220 | return createErrorResponse(
221 | baseMessage.ID,
222 | mcp.INTERNAL_ERROR,
223 | "Failed to make request",
224 | )
225 | }
226 | var responseMessage mcp.JSONRPCMessage
227 | if err := json.Unmarshal(response, &responseMessage); err != nil {
228 | return createErrorResponse(
229 | baseMessage.ID,
230 | mcp.INTERNAL_ERROR,
231 | "Failed to parse response",
232 | )
233 | }
234 | return responseMessage
235 | default:
236 | return createErrorResponse(
237 | baseMessage.ID,
238 | mcp.METHOD_NOT_FOUND,
239 | fmt.Sprintf("Method %s not supported", baseMessage.Method),
240 | )
241 | }
242 | }
243 |
244 | func (s *ProxyStdioServer) proxyMessage(
245 | ctx context.Context,
246 | line string,
247 | writer io.Writer,
248 | ) error {
249 | var rawMessage json.RawMessage
250 | if err := json.Unmarshal([]byte(line), &rawMessage); err != nil {
251 | response := createErrorResponse(nil, mcp.PARSE_ERROR, "Parse error")
252 | return s.writeResponse(response, writer)
253 | }
254 |
255 | response := s.handleMessage(ctx, rawMessage)
256 | if response != nil {
257 | return s.writeResponse(response, writer)
258 | }
259 | return nil
260 | }
261 |
262 | // writeResponse marshals and writes a JSON-RPC response message followed by a newline.
263 | // Returns an error if marshaling or writing fails.
264 | func (s *ProxyStdioServer) writeResponse(
265 | response mcp.JSONRPCMessage,
266 | writer io.Writer,
267 | ) error {
268 | responseBytes, err := json.Marshal(response)
269 | if err != nil {
270 | return err
271 | }
272 |
273 | // Write response followed by newline
274 | if _, err := fmt.Fprintf(writer, "%s\n", responseBytes); err != nil {
275 | return err
276 | }
277 |
278 | return nil
279 | }
280 |
281 | func ProxyServeStdio(baseURL string, timeout time.Duration) error {
282 | ps := NewProxyStdioServer(baseURL, timeout)
283 | ps.SetErrLogger(log.New(os.Stderr, "", log.LstdFlags))
284 |
285 | ctx, cancel := context.WithCancel(context.Background())
286 | defer cancel()
287 |
288 | sigChan := make(chan os.Signal, 1)
289 | signal.Notify(sigChan, syscall.SIGTERM, syscall.SIGINT)
290 |
291 | go func() {
292 | <-sigChan
293 | cancel()
294 | }()
295 |
296 | return ps.Listen(ctx, os.Stdin, os.Stdout)
297 | }
298 |
```