@ruco-ai/mcpster 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +235 -0
- package/dist/deploy/cli.d.ts +8 -0
- package/dist/deploy/cli.js +106 -0
- package/dist/deploy/cloudflare.d.ts +11 -0
- package/dist/deploy/cloudflare.js +57 -0
- package/dist/deploy/fly.d.ts +12 -0
- package/dist/deploy/fly.js +86 -0
- package/dist/deploy/railway.d.ts +19 -0
- package/dist/deploy/railway.js +34 -0
- package/dist/deploy/types.d.ts +18 -0
- package/dist/deploy/types.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/prompt.d.ts +3 -0
- package/dist/prompt.js +16 -0
- package/dist/resource.d.ts +3 -0
- package/dist/resource.js +36 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +51 -0
- package/dist/setup.d.ts +2 -0
- package/dist/setup.js +34 -0
- package/dist/tool.d.ts +4 -0
- package/dist/tool.js +17 -0
- package/dist/transport/http.d.ts +3 -0
- package/dist/transport/http.js +228 -0
- package/dist/transport/stdio.d.ts +2 -0
- package/dist/transport/stdio.js +6 -0
- package/dist/types.d.ts +45 -0
- package/dist/types.js +1 -0
- package/package.json +52 -0
package/README.md
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# mcpster
|
|
2
|
+
|
|
3
|
+
> A TypeScript SDK for building MCP servers — removes the boilerplate so you focus on what you expose, not how.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
mcpster is an agnostic TypeScript SDK for building Model Context Protocol (MCP) servers. It wraps `@modelcontextprotocol/sdk` behind a fluent, chainable API with Zod-first schema validation, URI template parsing, and consistent error handling. Servers built with mcpster start as local stdio processes and have a clear migration path to remote hosting and public npm distribution — without rewriting any business logic.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- `createServer()` + chainable `defineTool` / `defineResource` / `definePrompt` API
|
|
14
|
+
- Zod schema validation enforced at tool registration time
|
|
15
|
+
- URI template parameter extraction for resources (`templates://{name}`)
|
|
16
|
+
- Automatic error wrapping — handlers throw, mcpster returns MCP-compliant error responses
|
|
17
|
+
- Scope-aware server naming (project, user, or public scope)
|
|
18
|
+
- stdio and Streamable HTTP transports — switch with a single config field
|
|
19
|
+
- OAuth 2.1 (PKCE) support for protected remote servers — enable with `auth: true`
|
|
20
|
+
- Designed for progressive deployment: local stdio → remote Streamable HTTP → hosted → npm package
|
|
21
|
+
|
|
22
|
+
## Installation
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install mcpster
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
### stdio (default)
|
|
31
|
+
|
|
32
|
+
```typescript
|
|
33
|
+
import { createServer } from 'mcpster'
|
|
34
|
+
import { z } from 'zod'
|
|
35
|
+
|
|
36
|
+
createServer({ name: 'my-server', version: '1.0.0' })
|
|
37
|
+
.defineTool({
|
|
38
|
+
name: 'get_template',
|
|
39
|
+
description: 'Retrieve a template by name',
|
|
40
|
+
schema: z.object({ name: z.string() }),
|
|
41
|
+
handler: async ({ name }) => { /* ... */ },
|
|
42
|
+
})
|
|
43
|
+
.defineResource({
|
|
44
|
+
uri: 'templates://{name}',
|
|
45
|
+
description: 'Template content by name',
|
|
46
|
+
resolver: async ({ name }) => { /* ... */ },
|
|
47
|
+
})
|
|
48
|
+
.definePrompt({
|
|
49
|
+
name: 'summarize',
|
|
50
|
+
handler: async (args) => { /* ... */ },
|
|
51
|
+
})
|
|
52
|
+
.start() // stdio transport
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Register the server per-project:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
claude mcp add my-server -- npx mcpster start
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### Streamable HTTP transport
|
|
62
|
+
|
|
63
|
+
```typescript
|
|
64
|
+
import { createServer } from 'mcpster'
|
|
65
|
+
|
|
66
|
+
createServer({
|
|
67
|
+
name: 'my-server',
|
|
68
|
+
version: '1.0.0',
|
|
69
|
+
transport: 'http',
|
|
70
|
+
http: { port: 3000, path: '/mcp' },
|
|
71
|
+
})
|
|
72
|
+
.defineTool({ /* ... */ })
|
|
73
|
+
.start() // listens on http://localhost:3000/mcp
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Register with Claude Code:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
claude mcp add --transport http my-server http://localhost:3000/mcp
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### OAuth 2.1 (protected remote server)
|
|
83
|
+
|
|
84
|
+
Enable `auth: true` to require clients to complete an OAuth 2.1 PKCE flow before accessing the MCP endpoint. Set `baseUrl` to your public server URL so OAuth discovery metadata points to the right place.
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
createServer({
|
|
88
|
+
name: 'my-server',
|
|
89
|
+
version: '1.0.0',
|
|
90
|
+
transport: 'http',
|
|
91
|
+
http: {
|
|
92
|
+
port: 3000,
|
|
93
|
+
path: '/mcp',
|
|
94
|
+
auth: true,
|
|
95
|
+
baseUrl: 'https://my-server.example.com',
|
|
96
|
+
},
|
|
97
|
+
}).start()
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
mcpster handles the full OAuth flow: dynamic client registration (`/register`), authorization (`/authorize`), token issuance (`/token`), and bearer token verification on the MCP endpoint. All dynamically registered clients are treated as public PKCE clients — no pre-shared secrets required.
|
|
101
|
+
|
|
102
|
+
For persistence across restarts, point `clientsFile` at a file on a mounted volume:
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
http: {
|
|
106
|
+
auth: true,
|
|
107
|
+
baseUrl: 'https://my-server.example.com',
|
|
108
|
+
clientsFile: '/data/oauth-clients.json',
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Configuration
|
|
113
|
+
|
|
114
|
+
| Option | Default | Description |
|
|
115
|
+
|--------|---------|-------------|
|
|
116
|
+
| `name` | required | Server name, used for MCP registration |
|
|
117
|
+
| `version` | required | Semver string |
|
|
118
|
+
| `scope` | `process.cwd()` | Project root; determines scope for naming and resource resolution |
|
|
119
|
+
| `transport` | `'stdio'` | Transport to use: `'stdio'` or `'http'` |
|
|
120
|
+
| `http.port` | `3000` | Port to listen on (HTTP transport only) |
|
|
121
|
+
| `http.path` | `'/mcp'` | Request path to handle (HTTP transport only) |
|
|
122
|
+
| `http.auth` | `false` | Enable OAuth 2.1 PKCE protection on the MCP endpoint |
|
|
123
|
+
| `http.baseUrl` | `http://localhost:<port>` | Public base URL — used for OAuth metadata and token endpoints |
|
|
124
|
+
| `http.clientsFile` | *(in-memory)* | Path to persist registered OAuth clients across restarts |
|
|
125
|
+
|
|
126
|
+
## Project Structure
|
|
127
|
+
|
|
128
|
+
```
|
|
129
|
+
mcpster/
|
|
130
|
+
├── src/
|
|
131
|
+
│ ├── index.ts # Public API — re-exports createServer and types
|
|
132
|
+
│ ├── server.ts # McpsterServer class — core orchestrator
|
|
133
|
+
│ ├── tool.ts # defineTool — schema validation + handler wiring
|
|
134
|
+
│ ├── resource.ts # defineResource — URI template matching + resolver
|
|
135
|
+
│ ├── prompt.ts # definePrompt — prompt template registration
|
|
136
|
+
│ ├── transport/
|
|
137
|
+
│ │ ├── stdio.ts # stdio transport adapter
|
|
138
|
+
│ │ └── http.ts # Streamable HTTP transport adapter
|
|
139
|
+
│ ├── deploy/
|
|
140
|
+
│ │ ├── types.ts # Shared DeployConfig, DeployResult, DeployAdapter types
|
|
141
|
+
│ │ ├── railway.ts # Railway adapter (generateManifest, deploy)
|
|
142
|
+
│ │ ├── fly.ts # Fly.io adapter (generateManifest, generateDockerfile, deploy)
|
|
143
|
+
│ │ ├── cloudflare.ts # Cloudflare Workers adapter (generateManifest, generateWorkerShim, deploy)
|
|
144
|
+
│ │ └── cli.ts # mcpster-deploy CLI entry point
|
|
145
|
+
│ └── types.ts # Shared TypeScript types and interfaces
|
|
146
|
+
├── tests/
|
|
147
|
+
│ ├── server.test.ts
|
|
148
|
+
│ ├── tool.test.ts
|
|
149
|
+
│ ├── resource.test.ts
|
|
150
|
+
│ ├── prompt.test.ts
|
|
151
|
+
│ ├── transport.test.ts
|
|
152
|
+
│ └── deploy.test.ts
|
|
153
|
+
├── examples/
|
|
154
|
+
│ └── minimal/ # hello-mcp — tool + resource + prompt, runnable reference
|
|
155
|
+
├── package.json
|
|
156
|
+
└── tsconfig.json
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Deploy (v3)
|
|
160
|
+
|
|
161
|
+
mcpster ships a deploy kit that pushes your Streamable HTTP server to Railway, Fly.io, or Cloudflare Workers with a single command. The Streamable HTTP transport must already be configured on your server before deploying — the deploy adapters wrap the existing `transport: 'http'` path.
|
|
162
|
+
|
|
163
|
+
### Prerequisites
|
|
164
|
+
|
|
165
|
+
| Target | Prerequisite |
|
|
166
|
+
|--------|-------------|
|
|
167
|
+
| Railway | `railway` CLI installed and authenticated (`railway login`) |
|
|
168
|
+
| Fly.io | `flyctl` installed and authenticated (`fly auth login`) |
|
|
169
|
+
| Cloudflare Workers | `wrangler` installed and authenticated (`wrangler login`) |
|
|
170
|
+
|
|
171
|
+
### Usage
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
# Dry-run: print the generated manifest without deploying
|
|
175
|
+
npx mcpster-deploy --target railway --dry-run
|
|
176
|
+
npx mcpster-deploy --target fly --dry-run
|
|
177
|
+
npx mcpster-deploy --target cloudflare --dry-run
|
|
178
|
+
|
|
179
|
+
# Deploy
|
|
180
|
+
npx mcpster-deploy --target railway
|
|
181
|
+
npx mcpster-deploy --target fly --region lhr
|
|
182
|
+
npx mcpster-deploy --target cloudflare
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
All options:
|
|
186
|
+
|
|
187
|
+
```
|
|
188
|
+
-t, --target <target> Deploy target (railway | fly | cloudflare)
|
|
189
|
+
-n, --name <name> Server name (default: read from package.json)
|
|
190
|
+
-v, --version <version> Server version (default: read from package.json)
|
|
191
|
+
-p, --port <port> HTTP port (default: 3000)
|
|
192
|
+
-r, --region <region> Preferred region — Fly.io only (default: iad)
|
|
193
|
+
-d, --dry-run Print the generated manifest without deploying
|
|
194
|
+
-h, --help Show help
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Local → Hosted migration path
|
|
198
|
+
|
|
199
|
+
**Stage 1 — Local stdio** (no infrastructure)
|
|
200
|
+
|
|
201
|
+
```typescript
|
|
202
|
+
createServer({ name: 'my-server', version: '1.0.0' }).start()
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**Stage 2 — Local Streamable HTTP** (same server, different transport)
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
createServer({
|
|
209
|
+
name: 'my-server',
|
|
210
|
+
version: '1.0.0',
|
|
211
|
+
transport: 'http',
|
|
212
|
+
http: { port: 3000, path: '/mcp' },
|
|
213
|
+
}).start()
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**Stage 3 — Hosted** (one command, no code changes)
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
npx mcpster-deploy --target railway
|
|
220
|
+
# → Deployed to Railway: https://my-server.up.railway.app
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Then connect Claude Code to the deployed server:
|
|
224
|
+
|
|
225
|
+
```bash
|
|
226
|
+
claude mcp add --transport http my-server https://my-server.up.railway.app/mcp
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
The server code is identical across all three stages.
|
|
230
|
+
|
|
231
|
+
## License
|
|
232
|
+
|
|
233
|
+
MIT
|
|
234
|
+
|
|
235
|
+
---
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mcpster deploy CLI
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* mcpster-deploy --target <railway|fly|cloudflare> [--name <name>] [--version <v>] [--port <port>] [--dry-run]
|
|
7
|
+
*/
|
|
8
|
+
import { parseArgs } from 'node:util';
|
|
9
|
+
const { values } = parseArgs({
|
|
10
|
+
options: {
|
|
11
|
+
target: { type: 'string', short: 't' },
|
|
12
|
+
name: { type: 'string', short: 'n' },
|
|
13
|
+
version: { type: 'string', short: 'v' },
|
|
14
|
+
port: { type: 'string', short: 'p' },
|
|
15
|
+
region: { type: 'string', short: 'r' },
|
|
16
|
+
'dry-run': { type: 'boolean', short: 'd' },
|
|
17
|
+
help: { type: 'boolean', short: 'h' },
|
|
18
|
+
},
|
|
19
|
+
allowPositionals: false,
|
|
20
|
+
});
|
|
21
|
+
if (values.help || !values.target) {
|
|
22
|
+
console.log(`
|
|
23
|
+
mcpster deploy — push your MCP server to a hosted platform
|
|
24
|
+
|
|
25
|
+
Usage:
|
|
26
|
+
mcpster-deploy --target <target> [options]
|
|
27
|
+
|
|
28
|
+
Targets:
|
|
29
|
+
railway Deploy to Railway (requires: railway CLI)
|
|
30
|
+
fly Deploy to Fly.io (requires: flyctl)
|
|
31
|
+
cloudflare Deploy to Cloudflare Workers (requires: wrangler)
|
|
32
|
+
|
|
33
|
+
Options:
|
|
34
|
+
-t, --target <target> Deploy target (railway | fly | cloudflare)
|
|
35
|
+
-n, --name <name> Server name (default: read from package.json)
|
|
36
|
+
-v, --version <version> Server version (default: read from package.json)
|
|
37
|
+
-p, --port <port> HTTP port (default: 3000)
|
|
38
|
+
-r, --region <region> Preferred region (fly only, default: iad)
|
|
39
|
+
-d, --dry-run Print the generated manifest without deploying
|
|
40
|
+
-h, --help Show this help message
|
|
41
|
+
`);
|
|
42
|
+
process.exit(values.help ? 0 : 1);
|
|
43
|
+
}
|
|
44
|
+
const target = values.target;
|
|
45
|
+
if (!['railway', 'fly', 'cloudflare'].includes(target)) {
|
|
46
|
+
console.error(`Error: unknown target "${target}". Must be one of: railway, fly, cloudflare`);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
// Resolve name and version from package.json if not provided
|
|
50
|
+
async function resolvePackageMeta() {
|
|
51
|
+
try {
|
|
52
|
+
const { readFileSync } = await import('node:fs');
|
|
53
|
+
const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
|
|
54
|
+
return {
|
|
55
|
+
name: pkg.name ?? 'mcp-server',
|
|
56
|
+
version: pkg.version ?? '0.1.0',
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return { name: 'mcp-server', version: '0.1.0' };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const meta = await resolvePackageMeta();
|
|
64
|
+
const config = {
|
|
65
|
+
name: values.name ?? meta.name,
|
|
66
|
+
version: values.version ?? meta.version,
|
|
67
|
+
port: values.port ? Number(values.port) : 3000,
|
|
68
|
+
region: values.region,
|
|
69
|
+
};
|
|
70
|
+
const dryRun = values['dry-run'] ?? false;
|
|
71
|
+
if (target === 'railway') {
|
|
72
|
+
const { generateManifest, deploy } = await import('./railway.js');
|
|
73
|
+
if (dryRun) {
|
|
74
|
+
console.log(JSON.stringify(generateManifest(config), null, 2));
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
const result = await deploy(config);
|
|
78
|
+
console.log(`Deployed to Railway: ${result.url}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else if (target === 'fly') {
|
|
82
|
+
const { generateManifest, generateDockerfile, deploy } = await import('./fly.js');
|
|
83
|
+
if (dryRun) {
|
|
84
|
+
console.log('# fly.toml');
|
|
85
|
+
console.log(JSON.stringify(generateManifest(config), null, 2));
|
|
86
|
+
console.log('\n# Dockerfile');
|
|
87
|
+
console.log(generateDockerfile());
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
const result = await deploy(config);
|
|
91
|
+
console.log(`Deployed to Fly.io: ${result.url}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else if (target === 'cloudflare') {
|
|
95
|
+
const { generateManifest, manifestToToml, generateWorkerShim, deploy } = await import('./cloudflare.js');
|
|
96
|
+
if (dryRun) {
|
|
97
|
+
console.log('# wrangler.toml');
|
|
98
|
+
console.log(manifestToToml(generateManifest(config)));
|
|
99
|
+
console.log('\n# worker.js');
|
|
100
|
+
console.log(generateWorkerShim(config));
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
const result = await deploy(config);
|
|
104
|
+
console.log(`Deployed to Cloudflare Workers: ${result.url}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DeployConfig, DeployResult } from './types.js';
|
|
2
|
+
export interface CloudflareManifest {
|
|
3
|
+
name: string;
|
|
4
|
+
main: string;
|
|
5
|
+
compatibility_date: string;
|
|
6
|
+
vars: Record<string, string>;
|
|
7
|
+
}
|
|
8
|
+
export declare function generateManifest(config: DeployConfig): CloudflareManifest;
|
|
9
|
+
export declare function generateWorkerShim(config: DeployConfig): string;
|
|
10
|
+
export declare function manifestToToml(manifest: CloudflareManifest): string;
|
|
11
|
+
export declare function deploy(config: DeployConfig): Promise<DeployResult>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
export function generateManifest(config) {
|
|
3
|
+
return {
|
|
4
|
+
name: config.name,
|
|
5
|
+
main: 'worker.js',
|
|
6
|
+
compatibility_date: '2024-01-01',
|
|
7
|
+
vars: {
|
|
8
|
+
MCP_TRANSPORT: 'http',
|
|
9
|
+
SERVER_NAME: config.name,
|
|
10
|
+
SERVER_VERSION: config.version,
|
|
11
|
+
},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export function generateWorkerShim(config) {
|
|
15
|
+
const port = config.port ?? 3000;
|
|
16
|
+
return [
|
|
17
|
+
`// Worker entry shim for ${config.name} v${config.version}`,
|
|
18
|
+
`// Forwards requests to the MCP HTTP handler`,
|
|
19
|
+
`import { createServer } from './dist/index.js'`,
|
|
20
|
+
``,
|
|
21
|
+
`const server = createServer({`,
|
|
22
|
+
` name: '${config.name}',`,
|
|
23
|
+
` version: '${config.version}',`,
|
|
24
|
+
` transport: 'http',`,
|
|
25
|
+
` http: { port: ${port}, path: '/mcp' },`,
|
|
26
|
+
`})`,
|
|
27
|
+
``,
|
|
28
|
+
`export default {`,
|
|
29
|
+
` async fetch(request, env, ctx) {`,
|
|
30
|
+
` return server.handleRequest(request, env, ctx)`,
|
|
31
|
+
` },`,
|
|
32
|
+
`}`,
|
|
33
|
+
].join('\n');
|
|
34
|
+
}
|
|
35
|
+
export function manifestToToml(manifest) {
|
|
36
|
+
const lines = [
|
|
37
|
+
`name = "${manifest.name}"`,
|
|
38
|
+
`main = "${manifest.main}"`,
|
|
39
|
+
`compatibility_date = "${manifest.compatibility_date}"`,
|
|
40
|
+
``,
|
|
41
|
+
`[vars]`,
|
|
42
|
+
];
|
|
43
|
+
for (const [key, value] of Object.entries(manifest.vars)) {
|
|
44
|
+
lines.push(`${key} = "${value}"`);
|
|
45
|
+
}
|
|
46
|
+
return lines.join('\n');
|
|
47
|
+
}
|
|
48
|
+
export async function deploy(config) {
|
|
49
|
+
const manifest = generateManifest(config);
|
|
50
|
+
const { writeFileSync } = await import('node:fs');
|
|
51
|
+
writeFileSync('wrangler.toml', manifestToToml(manifest), 'utf8');
|
|
52
|
+
writeFileSync('worker.js', generateWorkerShim(config), 'utf8');
|
|
53
|
+
const output = execSync('wrangler deploy --json', { encoding: 'utf8' });
|
|
54
|
+
const result = JSON.parse(output);
|
|
55
|
+
const url = result.url ?? result.deployment_url ?? `https://${config.name}.workers.dev`;
|
|
56
|
+
return { url, target: 'cloudflare', manifest };
|
|
57
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { DeployConfig, DeployResult } from './types.js';
|
|
2
|
+
export interface FlyManifest {
|
|
3
|
+
app: string;
|
|
4
|
+
primary_region: string;
|
|
5
|
+
[services: string]: unknown;
|
|
6
|
+
}
|
|
7
|
+
export interface FlyDockerfile {
|
|
8
|
+
content: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function generateManifest(config: DeployConfig): FlyManifest;
|
|
11
|
+
export declare function generateDockerfile(): string;
|
|
12
|
+
export declare function deploy(config: DeployConfig): Promise<DeployResult>;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
import { readdirSync } from 'node:fs';
|
|
3
|
+
export function generateManifest(config) {
|
|
4
|
+
const port = config.port ?? 3000;
|
|
5
|
+
return {
|
|
6
|
+
app: config.name,
|
|
7
|
+
primary_region: config.region ?? 'iad',
|
|
8
|
+
build: { dockerfile: 'Dockerfile' },
|
|
9
|
+
http_service: {
|
|
10
|
+
internal_port: port,
|
|
11
|
+
force_https: true,
|
|
12
|
+
auto_stop_machines: true,
|
|
13
|
+
auto_start_machines: true,
|
|
14
|
+
},
|
|
15
|
+
vm: [
|
|
16
|
+
{
|
|
17
|
+
memory: config.memory ?? '256mb',
|
|
18
|
+
cpu_kind: 'shared',
|
|
19
|
+
cpus: 1,
|
|
20
|
+
},
|
|
21
|
+
],
|
|
22
|
+
env: {
|
|
23
|
+
PORT: String(port),
|
|
24
|
+
MCP_TRANSPORT: 'http',
|
|
25
|
+
SERVER_NAME: config.name,
|
|
26
|
+
SERVER_VERSION: config.version,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function generateDockerfile() {
|
|
31
|
+
let tgzFiles = [];
|
|
32
|
+
try {
|
|
33
|
+
tgzFiles = readdirSync('.').filter((f) => f.endsWith('.tgz'));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// ignore
|
|
37
|
+
}
|
|
38
|
+
const lines = [
|
|
39
|
+
'FROM node:20-alpine',
|
|
40
|
+
'WORKDIR /app',
|
|
41
|
+
'COPY package*.json ./',
|
|
42
|
+
...(tgzFiles.length > 0 ? [`COPY ${tgzFiles.join(' ')} ./`] : []),
|
|
43
|
+
'RUN npm ci --omit=dev',
|
|
44
|
+
'COPY dist ./dist',
|
|
45
|
+
'COPY mcpster.config.json ./',
|
|
46
|
+
'EXPOSE 3000',
|
|
47
|
+
'CMD ["node", "dist/index.js"]',
|
|
48
|
+
];
|
|
49
|
+
return lines.join('\n');
|
|
50
|
+
}
|
|
51
|
+
export async function deploy(config) {
|
|
52
|
+
const manifest = generateManifest(config);
|
|
53
|
+
const { writeFileSync } = await import('node:fs');
|
|
54
|
+
writeFileSync('fly.toml', toToml(manifest), 'utf8');
|
|
55
|
+
writeFileSync('Dockerfile', generateDockerfile(), 'utf8');
|
|
56
|
+
execSync('fly deploy', { encoding: 'utf8', stdio: 'inherit' });
|
|
57
|
+
const infoOutput = execSync(`fly status --app ${config.name} --json`, { encoding: 'utf8' });
|
|
58
|
+
const info = JSON.parse(infoOutput);
|
|
59
|
+
const url = info.Hostname ? `https://${info.Hostname}` : '';
|
|
60
|
+
return { url, target: 'fly', manifest };
|
|
61
|
+
}
|
|
62
|
+
function toToml(obj, indent = 0) {
|
|
63
|
+
const lines = [];
|
|
64
|
+
const pad = ' '.repeat(indent);
|
|
65
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
66
|
+
if (value === null || value === undefined)
|
|
67
|
+
continue;
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
for (const item of value) {
|
|
70
|
+
lines.push(`${pad}[[${key}]]`);
|
|
71
|
+
lines.push(toToml(item, indent + 2));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else if (typeof value === 'object') {
|
|
75
|
+
lines.push(`${pad}[${key}]`);
|
|
76
|
+
lines.push(toToml(value, indent + 2));
|
|
77
|
+
}
|
|
78
|
+
else if (typeof value === 'string') {
|
|
79
|
+
lines.push(`${pad}${key} = "${value}"`);
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
lines.push(`${pad}${key} = ${String(value)}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return lines.join('\n');
|
|
86
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { DeployConfig, DeployResult } from './types.js';
|
|
2
|
+
export interface RailwayManifest {
|
|
3
|
+
'$schema': string;
|
|
4
|
+
build: {
|
|
5
|
+
builder: string;
|
|
6
|
+
};
|
|
7
|
+
deploy: {
|
|
8
|
+
startCommand: string;
|
|
9
|
+
healthcheckPath: string;
|
|
10
|
+
restartPolicyType: string;
|
|
11
|
+
};
|
|
12
|
+
environments: {
|
|
13
|
+
production: {
|
|
14
|
+
variables: Record<string, string>;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export declare function generateManifest(config: DeployConfig): RailwayManifest;
|
|
19
|
+
export declare function deploy(config: DeployConfig): Promise<DeployResult>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
export function generateManifest(config) {
|
|
3
|
+
const port = config.port ?? 3000;
|
|
4
|
+
return {
|
|
5
|
+
'$schema': 'https://railway.app/railway.schema.json',
|
|
6
|
+
build: { builder: 'NIXPACKS' },
|
|
7
|
+
deploy: {
|
|
8
|
+
startCommand: `node dist/index.js`,
|
|
9
|
+
healthcheckPath: '/mcp',
|
|
10
|
+
restartPolicyType: 'ON_FAILURE',
|
|
11
|
+
},
|
|
12
|
+
environments: {
|
|
13
|
+
production: {
|
|
14
|
+
variables: {
|
|
15
|
+
PORT: String(port),
|
|
16
|
+
MCP_TRANSPORT: 'http',
|
|
17
|
+
SERVER_NAME: config.name,
|
|
18
|
+
SERVER_VERSION: config.version,
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export async function deploy(config) {
|
|
25
|
+
const manifest = generateManifest(config);
|
|
26
|
+
const json = JSON.stringify(manifest, null, 2);
|
|
27
|
+
// Write railway.json then invoke CLI
|
|
28
|
+
const { writeFileSync } = await import('node:fs');
|
|
29
|
+
writeFileSync('railway.json', json, 'utf8');
|
|
30
|
+
const output = execSync('railway up --json', { encoding: 'utf8' });
|
|
31
|
+
const result = JSON.parse(output);
|
|
32
|
+
const url = result.url ?? result.deploymentUrl ?? '';
|
|
33
|
+
return { url, target: 'railway', manifest };
|
|
34
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type DeployTarget = 'railway' | 'fly' | 'cloudflare';
|
|
2
|
+
export interface DeployConfig {
|
|
3
|
+
name: string;
|
|
4
|
+
version: string;
|
|
5
|
+
port?: number;
|
|
6
|
+
region?: string;
|
|
7
|
+
memory?: string;
|
|
8
|
+
cpu?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface DeployResult {
|
|
11
|
+
url: string;
|
|
12
|
+
target: DeployTarget;
|
|
13
|
+
manifest: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface DeployAdapter {
|
|
16
|
+
generateManifest(config: DeployConfig): unknown;
|
|
17
|
+
deploy(config: DeployConfig): Promise<DeployResult>;
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { createServer } from './server.js';
|
package/dist/prompt.d.ts
ADDED
package/dist/prompt.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function registerPrompt(sdk, def) {
|
|
2
|
+
sdk.registerPrompt(def.name, { description: def.description }, async (args) => {
|
|
3
|
+
try {
|
|
4
|
+
const text = await def.handler(args);
|
|
5
|
+
return {
|
|
6
|
+
messages: [{ role: 'user', content: { type: 'text', text } }],
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
catch (err) {
|
|
10
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11
|
+
return {
|
|
12
|
+
messages: [{ role: 'user', content: { type: 'text', text: message } }],
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
}
|
package/dist/resource.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
function parseUriParams(uri) {
|
|
3
|
+
return [...uri.matchAll(/\{(\w+)\}/g)].map(m => m[1]);
|
|
4
|
+
}
|
|
5
|
+
export function registerResource(sdk, def) {
|
|
6
|
+
const params = parseUriParams(def.uri);
|
|
7
|
+
if (params.length === 0) {
|
|
8
|
+
sdk.registerResource(def.uri, def.uri, { description: def.description }, async () => {
|
|
9
|
+
try {
|
|
10
|
+
const text = await def.resolver({});
|
|
11
|
+
return { contents: [{ uri: def.uri, text }] };
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
15
|
+
return { contents: [{ uri: def.uri, text: message }] };
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
const template = new ResourceTemplate(def.uri, { list: undefined });
|
|
21
|
+
sdk.registerResource(def.uri, template, { description: def.description }, async (uri, variables) => {
|
|
22
|
+
try {
|
|
23
|
+
const extracted = {};
|
|
24
|
+
for (const [k, v] of Object.entries(variables)) {
|
|
25
|
+
extracted[k] = Array.isArray(v) ? v[0] : String(v);
|
|
26
|
+
}
|
|
27
|
+
const text = await def.resolver(extracted);
|
|
28
|
+
return { contents: [{ uri: uri.toString(), text }] };
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
32
|
+
return { contents: [{ uri: uri.toString(), text: message }] };
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
package/dist/server.d.ts
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { connectStdio } from './transport/stdio.js';
|
|
3
|
+
import { connectHttp } from './transport/http.js';
|
|
4
|
+
import { registerTool } from './tool.js';
|
|
5
|
+
import { registerResource } from './resource.js';
|
|
6
|
+
import { registerPrompt } from './prompt.js';
|
|
7
|
+
import { applySetup } from './setup.js';
|
|
8
|
+
class McpsterServerImpl {
|
|
9
|
+
sdk;
|
|
10
|
+
config;
|
|
11
|
+
toolNames = [];
|
|
12
|
+
_stop;
|
|
13
|
+
constructor(config) {
|
|
14
|
+
this.config = config;
|
|
15
|
+
this.sdk = new McpServer({
|
|
16
|
+
name: config.name,
|
|
17
|
+
version: config.version,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
defineTool(def) {
|
|
21
|
+
registerTool(this.sdk, def);
|
|
22
|
+
this.toolNames.push(def.name);
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
defineResource(def) {
|
|
26
|
+
registerResource(this.sdk, def);
|
|
27
|
+
return this;
|
|
28
|
+
}
|
|
29
|
+
definePrompt(def) {
|
|
30
|
+
registerPrompt(this.sdk, def);
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
async setup(options) {
|
|
34
|
+
applySetup(this.config.name, this.toolNames, options);
|
|
35
|
+
return this;
|
|
36
|
+
}
|
|
37
|
+
async start() {
|
|
38
|
+
if (this.config.transport === 'http') {
|
|
39
|
+
this._stop = await connectHttp(this.sdk, this.config.http);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
this._stop = await connectStdio(this.sdk);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async stop() {
|
|
46
|
+
await this._stop?.();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function createServer(config) {
|
|
50
|
+
return new McpsterServerImpl(config);
|
|
51
|
+
}
|
package/dist/setup.d.ts
ADDED
package/dist/setup.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
function claudeJsonPath() {
|
|
5
|
+
return resolve(homedir(), '.claude.json');
|
|
6
|
+
}
|
|
7
|
+
function readClaudeJson() {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(readFileSync(claudeJsonPath(), 'utf8'));
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
return {};
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
function writeClaudeJson(data) {
|
|
16
|
+
writeFileSync(claudeJsonPath(), JSON.stringify(data, null, 2) + '\n', 'utf8');
|
|
17
|
+
}
|
|
18
|
+
export function applySetup(serverName, toolNames, options = {}) {
|
|
19
|
+
const projectPath = resolve(options.projectPath ?? process.cwd());
|
|
20
|
+
const permissions = options.permissions ?? 'restrictive';
|
|
21
|
+
const claude = readClaudeJson();
|
|
22
|
+
const projects = (claude.projects ?? {});
|
|
23
|
+
const project = projects[projectPath] ?? {};
|
|
24
|
+
const allowedTools = permissions === 'permissive'
|
|
25
|
+
? toolNames.map(name => `${serverName}:${name}`)
|
|
26
|
+
: [];
|
|
27
|
+
projects[projectPath] = { ...project, allowedTools };
|
|
28
|
+
claude.projects = projects;
|
|
29
|
+
writeClaudeJson(claude);
|
|
30
|
+
console.error(`[mcpster] setup complete — permissions: ${permissions}`);
|
|
31
|
+
if (allowedTools.length > 0) {
|
|
32
|
+
console.error(`[mcpster] allowed tools: ${allowedTools.join(', ')}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
package/dist/tool.d.ts
ADDED
package/dist/tool.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export function registerTool(sdk, def) {
|
|
2
|
+
const shape = def.schema.shape;
|
|
3
|
+
sdk.registerTool(def.name, {
|
|
4
|
+
description: def.description,
|
|
5
|
+
inputSchema: shape,
|
|
6
|
+
}, async (args) => {
|
|
7
|
+
try {
|
|
8
|
+
const result = await def.handler(args);
|
|
9
|
+
const text = typeof result === 'string' ? result : JSON.stringify(result);
|
|
10
|
+
return { content: [{ type: 'text', text }] };
|
|
11
|
+
}
|
|
12
|
+
catch (err) {
|
|
13
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
14
|
+
return { content: [{ type: 'text', text: message }], isError: true };
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import express from 'express';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
6
|
+
import { mcpAuthRouter } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
|
7
|
+
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
|
8
|
+
function createClientsStore(clientsFile) {
|
|
9
|
+
const clients = new Map();
|
|
10
|
+
if (clientsFile) {
|
|
11
|
+
try {
|
|
12
|
+
const raw = readFileSync(clientsFile, 'utf8');
|
|
13
|
+
const stored = JSON.parse(raw);
|
|
14
|
+
for (const c of stored)
|
|
15
|
+
clients.set(c.client_id, c);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
// File doesn't exist yet — start empty
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function persist() {
|
|
22
|
+
if (!clientsFile)
|
|
23
|
+
return;
|
|
24
|
+
try {
|
|
25
|
+
mkdirSync(dirname(clientsFile), { recursive: true });
|
|
26
|
+
writeFileSync(clientsFile, JSON.stringify([...clients.values()], null, 2), 'utf8');
|
|
27
|
+
}
|
|
28
|
+
catch (err) {
|
|
29
|
+
console.error('[mcpster] Failed to persist OAuth clients:', err);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
getClient: (id) => clients.get(id) ?? undefined,
|
|
34
|
+
registerClient: (client) => {
|
|
35
|
+
// Always treat dynamically registered clients as public (no client_secret).
|
|
36
|
+
// This allows clients like Claude.ai that use PKCE and never send a secret
|
|
37
|
+
// to complete the token exchange, regardless of what they sent at registration.
|
|
38
|
+
const { client_secret: _secret, client_secret_expires_at: _exp, ...rest } = client;
|
|
39
|
+
const full = {
|
|
40
|
+
redirect_uris: [],
|
|
41
|
+
...rest,
|
|
42
|
+
token_endpoint_auth_method: 'none',
|
|
43
|
+
client_id: randomUUID(),
|
|
44
|
+
client_id_issued_at: Math.floor(Date.now() / 1000),
|
|
45
|
+
};
|
|
46
|
+
clients.set(full.client_id, full);
|
|
47
|
+
persist();
|
|
48
|
+
return full;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// OAuth 2.1 provider (PKCE, dynamic client registration)
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
function createOAuthProvider(clientsFile) {
|
|
56
|
+
const clientsStore = createClientsStore(clientsFile);
|
|
57
|
+
const authCodes = new Map();
|
|
58
|
+
const tokens = new Map();
|
|
59
|
+
const refreshTokens = new Map();
|
|
60
|
+
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
61
|
+
return {
|
|
62
|
+
get clientsStore() { return clientsStore; },
|
|
63
|
+
async authorize(client, params, res) {
|
|
64
|
+
const code = randomUUID();
|
|
65
|
+
authCodes.set(code, {
|
|
66
|
+
clientId: client.client_id,
|
|
67
|
+
codeChallenge: params.codeChallenge,
|
|
68
|
+
redirectUri: params.redirectUri,
|
|
69
|
+
scopes: params.scopes ?? [],
|
|
70
|
+
});
|
|
71
|
+
res.send(`<!DOCTYPE html>
|
|
72
|
+
<html lang="en">
|
|
73
|
+
<head>
|
|
74
|
+
<meta charset="utf-8">
|
|
75
|
+
<title>Authorize — ${esc(client.client_name ?? client.client_id)}</title>
|
|
76
|
+
<style>
|
|
77
|
+
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 80px auto; padding: 0 20px; }
|
|
78
|
+
h2 { margin-bottom: 8px; }
|
|
79
|
+
p { color: #555; margin-bottom: 24px; }
|
|
80
|
+
button { padding: 10px 20px; border-radius: 6px; font-size: 15px; cursor: pointer; border: 1px solid #ccc; }
|
|
81
|
+
.approve { background: #0070f3; color: #fff; border-color: #0070f3; margin-right: 10px; }
|
|
82
|
+
</style>
|
|
83
|
+
</head>
|
|
84
|
+
<body>
|
|
85
|
+
<h2>Authorization Request</h2>
|
|
86
|
+
<p><strong>${esc(client.client_name ?? client.client_id)}</strong> is requesting access to this MCP server.</p>
|
|
87
|
+
<form method="POST" action="/approve">
|
|
88
|
+
<input type="hidden" name="code" value="${esc(code)}">
|
|
89
|
+
<input type="hidden" name="redirect_uri" value="${esc(params.redirectUri)}">
|
|
90
|
+
<input type="hidden" name="state" value="${esc(params.state ?? '')}">
|
|
91
|
+
<button class="approve" type="submit">Approve</button>
|
|
92
|
+
<button type="button" onclick="history.back()">Deny</button>
|
|
93
|
+
</form>
|
|
94
|
+
</body>
|
|
95
|
+
</html>`);
|
|
96
|
+
},
|
|
97
|
+
async challengeForAuthorizationCode(client, code) {
|
|
98
|
+
const entry = authCodes.get(code);
|
|
99
|
+
if (!entry || entry.clientId !== client.client_id)
|
|
100
|
+
throw new Error('Invalid authorization code');
|
|
101
|
+
return entry.codeChallenge;
|
|
102
|
+
},
|
|
103
|
+
async exchangeAuthorizationCode(client, code) {
|
|
104
|
+
const entry = authCodes.get(code);
|
|
105
|
+
if (!entry || entry.clientId !== client.client_id)
|
|
106
|
+
throw new Error('Invalid authorization code');
|
|
107
|
+
authCodes.delete(code);
|
|
108
|
+
const accessToken = randomUUID();
|
|
109
|
+
const refreshToken = randomUUID();
|
|
110
|
+
const expiresAt = Math.floor(Date.now() / 1000) + 3600;
|
|
111
|
+
tokens.set(accessToken, { token: accessToken, clientId: client.client_id, scopes: entry.scopes, expiresAt, refreshToken });
|
|
112
|
+
refreshTokens.set(refreshToken, accessToken);
|
|
113
|
+
return { access_token: accessToken, token_type: 'bearer', expires_in: 3600, refresh_token: refreshToken };
|
|
114
|
+
},
|
|
115
|
+
async exchangeRefreshToken(client, oldRefreshToken) {
|
|
116
|
+
const oldAccess = refreshTokens.get(oldRefreshToken);
|
|
117
|
+
if (!oldAccess)
|
|
118
|
+
throw new Error('Invalid refresh token');
|
|
119
|
+
const old = tokens.get(oldAccess);
|
|
120
|
+
if (!old)
|
|
121
|
+
throw new Error('Invalid refresh token');
|
|
122
|
+
const accessToken = randomUUID();
|
|
123
|
+
const newRefresh = randomUUID();
|
|
124
|
+
const expiresAt = Math.floor(Date.now() / 1000) + 3600;
|
|
125
|
+
tokens.delete(oldAccess);
|
|
126
|
+
refreshTokens.delete(oldRefreshToken);
|
|
127
|
+
tokens.set(accessToken, { token: accessToken, clientId: client.client_id, scopes: old.scopes, expiresAt, refreshToken: newRefresh });
|
|
128
|
+
refreshTokens.set(newRefresh, accessToken);
|
|
129
|
+
return { access_token: accessToken, token_type: 'bearer', expires_in: 3600, refresh_token: newRefresh };
|
|
130
|
+
},
|
|
131
|
+
async verifyAccessToken(token) {
|
|
132
|
+
const info = tokens.get(token);
|
|
133
|
+
if (!info)
|
|
134
|
+
throw new Error('Invalid token');
|
|
135
|
+
if (info.expiresAt < Math.floor(Date.now() / 1000))
|
|
136
|
+
throw new Error('Token expired');
|
|
137
|
+
return info;
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// HTTP transport with OAuth 2.1
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
export async function connectHttp(server, config) {
|
|
145
|
+
const port = config?.port ?? 3000;
|
|
146
|
+
const path = config?.path ?? '/mcp';
|
|
147
|
+
// baseUrl drives OAuth metadata (issuer, token endpoint, etc.).
|
|
148
|
+
// Priority: config.baseUrl → BASE_URL env → http://localhost:<port>
|
|
149
|
+
const envBaseUrl = process.env.BASE_URL;
|
|
150
|
+
const rawBaseUrl = config?.baseUrl || (envBaseUrl?.startsWith('http') ? envBaseUrl : undefined) || `http://localhost:${port}`;
|
|
151
|
+
const baseUrl = new URL(rawBaseUrl);
|
|
152
|
+
const authEnabled = config?.auth ?? false;
|
|
153
|
+
const app = express();
|
|
154
|
+
// Behind a reverse proxy (Fly.io, Railway, etc.) there is exactly one hop.
|
|
155
|
+
app.set('trust proxy', 1);
|
|
156
|
+
app.use(express.json());
|
|
157
|
+
app.use(express.urlencoded({ extended: false }));
|
|
158
|
+
// Request logging
|
|
159
|
+
app.use((req, _res, next) => {
|
|
160
|
+
console.log(`[mcpster] ${req.method} ${req.path}`, req.body && Object.keys(req.body).length ? JSON.stringify(req.body) : '');
|
|
161
|
+
next();
|
|
162
|
+
});
|
|
163
|
+
if (authEnabled) {
|
|
164
|
+
if (!config?.clientsFile && !process.env.MCPSTER_CLIENTS_FILE) {
|
|
165
|
+
console.warn('[mcpster] Warning: running HTTP transport without clientsFile — registered OAuth clients ' +
|
|
166
|
+
'are stored in memory only and will be lost on restart. Set http.clientsFile in ' +
|
|
167
|
+
'mcpster.config.json (e.g. "/data/oauth-clients.json") and mount a persistent volume.');
|
|
168
|
+
}
|
|
169
|
+
const provider = createOAuthProvider(config?.clientsFile ?? process.env.MCPSTER_CLIENTS_FILE);
|
|
170
|
+
// OAuth discovery + registration + token endpoints
|
|
171
|
+
app.use(mcpAuthRouter({ provider, issuerUrl: baseUrl }));
|
|
172
|
+
// Approve endpoint — browser page shown during the OAuth flow
|
|
173
|
+
app.post('/approve', (req, res) => {
|
|
174
|
+
const { code, redirect_uri, state } = req.body;
|
|
175
|
+
if (!code || !redirect_uri) {
|
|
176
|
+
res.status(400).send('Missing code or redirect_uri');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const url = new URL(redirect_uri);
|
|
180
|
+
url.searchParams.set('code', code);
|
|
181
|
+
if (state)
|
|
182
|
+
url.searchParams.set('state', state);
|
|
183
|
+
res.redirect(url.toString());
|
|
184
|
+
});
|
|
185
|
+
// MCP endpoint — protected by bearer token
|
|
186
|
+
// resourceMetadataUrl tells clients where to find OAuth discovery metadata (RFC 9728)
|
|
187
|
+
const resourceMetadataUrl = new URL('/.well-known/oauth-protected-resource', baseUrl).href;
|
|
188
|
+
app.all(path, requireBearerAuth({ verifier: provider, resourceMetadataUrl }), async (req, res) => {
|
|
189
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: config?.enableJsonResponse });
|
|
190
|
+
try {
|
|
191
|
+
await server.connect(transport);
|
|
192
|
+
await transport.handleRequest(req, res, req.body);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
if (!res.headersSent)
|
|
196
|
+
res.status(500).end();
|
|
197
|
+
}
|
|
198
|
+
finally {
|
|
199
|
+
await transport.close();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
// No auth — MCP endpoint is open
|
|
205
|
+
app.all(path, async (req, res) => {
|
|
206
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: config?.enableJsonResponse });
|
|
207
|
+
try {
|
|
208
|
+
await server.connect(transport);
|
|
209
|
+
await transport.handleRequest(req, res, req.body);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
if (!res.headersSent)
|
|
213
|
+
res.status(500).end();
|
|
214
|
+
}
|
|
215
|
+
finally {
|
|
216
|
+
await transport.close();
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const httpServer = app.listen(port);
|
|
221
|
+
await new Promise((resolve, reject) => {
|
|
222
|
+
httpServer.once('listening', resolve);
|
|
223
|
+
httpServer.once('error', reject);
|
|
224
|
+
});
|
|
225
|
+
return () => new Promise((resolve, reject) => {
|
|
226
|
+
httpServer.close((err) => (err ? reject(err) : resolve()));
|
|
227
|
+
});
|
|
228
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { z, ZodSchema } from 'zod';
|
|
2
|
+
export interface HttpConfig {
|
|
3
|
+
port?: number;
|
|
4
|
+
path?: string;
|
|
5
|
+
baseUrl?: string;
|
|
6
|
+
auth?: boolean;
|
|
7
|
+
clientsFile?: string;
|
|
8
|
+
enableJsonResponse?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface ServerConfig {
|
|
11
|
+
name: string;
|
|
12
|
+
version: string;
|
|
13
|
+
scope?: string;
|
|
14
|
+
transport?: 'stdio' | 'http';
|
|
15
|
+
http?: HttpConfig;
|
|
16
|
+
}
|
|
17
|
+
export interface ToolDefinition<T extends ZodSchema = ZodSchema> {
|
|
18
|
+
name: string;
|
|
19
|
+
description: string;
|
|
20
|
+
schema: T;
|
|
21
|
+
handler: (input: z.infer<T>) => Promise<unknown>;
|
|
22
|
+
}
|
|
23
|
+
export interface ResourceDefinition {
|
|
24
|
+
uri: string;
|
|
25
|
+
description: string;
|
|
26
|
+
resolver: (params: Record<string, string>) => Promise<string>;
|
|
27
|
+
}
|
|
28
|
+
export interface PromptDefinition {
|
|
29
|
+
name: string;
|
|
30
|
+
description?: string;
|
|
31
|
+
handler: (args: Record<string, string>) => Promise<string>;
|
|
32
|
+
}
|
|
33
|
+
export type PermissionMode = 'permissive' | 'restrictive';
|
|
34
|
+
export interface SetupOptions {
|
|
35
|
+
permissions?: PermissionMode;
|
|
36
|
+
projectPath?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface McpsterServer {
|
|
39
|
+
defineTool<T extends ZodSchema>(def: ToolDefinition<T>): McpsterServer;
|
|
40
|
+
defineResource(def: ResourceDefinition): McpsterServer;
|
|
41
|
+
definePrompt(def: PromptDefinition): McpsterServer;
|
|
42
|
+
setup(options?: SetupOptions): Promise<McpsterServer>;
|
|
43
|
+
start(): Promise<void>;
|
|
44
|
+
stop(): Promise<void>;
|
|
45
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ruco-ai/mcpster",
|
|
3
|
+
"version": "0.2.5",
|
|
4
|
+
"description": "Agnostic TypeScript SDK for building MCP servers",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "ruco",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"mcp",
|
|
9
|
+
"model-context-protocol",
|
|
10
|
+
"sdk",
|
|
11
|
+
"typescript",
|
|
12
|
+
"llm",
|
|
13
|
+
"ai"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "https://github.com/ruco-ai/mcpster"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"import": "./dist/index.js",
|
|
25
|
+
"types": "./dist/index.d.ts"
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"mcpster-deploy": "./dist/deploy/cli.js"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc",
|
|
36
|
+
"dev": "tsc --watch",
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"test:watch": "vitest",
|
|
39
|
+
"prepublishOnly": "npm run build"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
43
|
+
"express": "^5",
|
|
44
|
+
"zod": "^3"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/express": "^5.0.6",
|
|
48
|
+
"@types/node": "^25.5.2",
|
|
49
|
+
"typescript": "^5",
|
|
50
|
+
"vitest": "^4.1.2"
|
|
51
|
+
}
|
|
52
|
+
}
|