@ninomae/mcp-app-server 0.1.0
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/CHANGELOG.md +24 -0
- package/LICENSE +21 -0
- package/README.md +262 -0
- package/README.zh-CN.md +204 -0
- package/dist/identity.d.ts +27 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +62 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +134 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp.d.ts +64 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +76 -0
- package/dist/mcp.js.map +1 -0
- package/dist/oauth.d.ts +33 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +338 -0
- package/dist/oauth.js.map +1 -0
- package/dist/rate-limit.d.ts +20 -0
- package/dist/rate-limit.d.ts.map +1 -0
- package/dist/rate-limit.js +29 -0
- package/dist/rate-limit.js.map +1 -0
- package/dist/react.d.ts +36 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +73 -0
- package/dist/react.js.map +1 -0
- package/dist/sql.d.ts +27 -0
- package/dist/sql.d.ts.map +1 -0
- package/dist/sql.js +107 -0
- package/dist/sql.js.map +1 -0
- package/dist/store.d.ts +106 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +122 -0
- package/dist/store.js.map +1 -0
- package/dist/tokens.d.ts +26 -0
- package/dist/tokens.d.ts.map +1 -0
- package/dist/tokens.js +61 -0
- package/dist/tokens.js.map +1 -0
- package/dist/types.d.ts +152 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -0
- package/dist/util.d.ts +9 -0
- package/dist/util.d.ts.map +1 -0
- package/dist/util.js +48 -0
- package/dist/util.js.map +1 -0
- package/package.json +96 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/).
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-09-15
|
|
8
|
+
|
|
9
|
+
Initial release. Extracted from the Career Note monorepo (where it was briefly named `agent-gateway`) and generalised.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `createMcpAppServer`: OAuth 2.1 authorization server (RFC 6749 / 7591 / 7636 / 8414 / 8707 / 9728) and MCP Streamable HTTP resource server in one `fetch` handler.
|
|
13
|
+
- Identity providers: `sessionIdentity`, `jwtIdentity`, `firebaseIdentity`, `fixedIdentity`.
|
|
14
|
+
- Scoped tool table with grant-trimmed `tools/list`, anonymous discovery and a public `/mcp/schema` document. `scopes` is optional: without a catalogue one implicit required scope is used and consent is a plain allow/deny.
|
|
15
|
+
- Opaque access tokens (`agt_`) and rotating refresh tokens (`agr_`), hashed at rest, audience-bound; replaying a code or a rotated refresh token revokes the chain.
|
|
16
|
+
- `authenticate` / `carriesToken` for accepting agent tokens on host routes; `listGrants` / `revokeGrant` for user-facing management.
|
|
17
|
+
- Storage contract `AppServerStore`: the core depends on no database. `memoryStore()` (reference, Maps) ships in the root; `sqlStore(db, tables?)` for Cloudflare D1 and SQLite drivers ships at `@ninomae/mcp-app-server/sql`.
|
|
18
|
+
- Rate-limit contract `RateLimiter` for dynamic client registration (`registrationLimit`); `memoryRateLimiter()` is the default, `false` disables.
|
|
19
|
+
- `serverJson` helper for the official MCP Registry.
|
|
20
|
+
- `@ninomae/mcp-app-server/react`: headless `useAgentConsent` hook.
|
|
21
|
+
- Tests: Miniflare + D1 end-to-end, replay/revocation rules on `memoryStore`, `sqlStore` on plain Node `node:sqlite`.
|
|
22
|
+
|
|
23
|
+
[Unreleased]: https://github.com/erzhiqianyi/mcp-app-server/compare/v0.1.0...HEAD
|
|
24
|
+
[0.1.0]: https://github.com/erzhiqianyi/mcp-app-server/releases/tag/v0.1.0
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 erzhiqianyi and contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# @ninomae/mcp-app-server
|
|
2
|
+
|
|
3
|
+
**Turn your existing app into an OAuth 2.1-protected MCP server.**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/@ninomae/mcp-app-server)
|
|
6
|
+
[](https://github.com/erzhiqianyi/mcp-app-server/actions/workflows/ci.yml)
|
|
7
|
+
[](./LICENSE)
|
|
8
|
+
|
|
9
|
+
Expose **your existing app's users and data** to external AI agents (Claude, ChatGPT, Cursor, Claude Code, any MCP client) over standard **MCP + OAuth 2.1**. You do not integrate a model; users bring their own agent, read their data, work on it there, and write results back through tools you define.
|
|
10
|
+
|
|
11
|
+
[中文文档](./README.zh-CN.md) · [Integration guide](./docs/integration.md) · [Publishing](./docs/publishing.md) · [Changelog](./CHANGELOG.md)
|
|
12
|
+
|
|
13
|
+
- **Bring your own identity.** Your app already has a login (Firebase, Supabase, Auth0, Clerk, a session cookie, home-grown). You implement one function: `resolve(request) → { id }`.
|
|
14
|
+
- **It is the OAuth authorization server.** MCP clients require dynamic client registration (RFC 7591), PKCE, resource indicators (RFC 8707), refresh-token rotation and per-agent revocation. Consumer identity providers rarely offer these, so this layer has to live in your app.
|
|
15
|
+
- **A scoped tool table is your product's agent surface.** Each tool declares the scope it needs; `tools/list` is trimmed to what the user actually granted.
|
|
16
|
+
- **Publicly discoverable.** `/.well-known/*`, `GET <basePath>/mcp/schema`, and anonymous `initialize` / `tools/list` need no token, so the server can be submitted to the official MCP Registry or a connector directory.
|
|
17
|
+
- **Storage-agnostic.** The core depends on no database. Implement the small `AppServerStore` interface over whatever you run (SQL, KV, Redis, Mongo, an ORM), or use the bundled `memoryStore()` / `sqlStore()` (Cloudflare D1, any SQLite driver).
|
|
18
|
+
|
|
19
|
+
## Install
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install @ninomae/mcp-app-server @modelcontextprotocol/sdk zod
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Optional peers: `jose` (for `jwtIdentity` / `firebaseIdentity`) and `react` (for the `/react` consent hook). Node ≥ 22 or any Web-standard runtime (Cloudflare Workers, Deno, Bun).
|
|
26
|
+
|
|
27
|
+
## Quick start
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { z } from 'zod';
|
|
31
|
+
import { createMcpAppServer, sessionIdentity } from '@ninomae/mcp-app-server';
|
|
32
|
+
import { sqlStore } from '@ninomae/mcp-app-server/sql';
|
|
33
|
+
|
|
34
|
+
const mcp = createMcpAppServer({
|
|
35
|
+
name: 'notes',
|
|
36
|
+
basePath: '/api/notes', // → /api/notes/mcp, /api/notes/mcp/schema, /api/notes/oauth/*
|
|
37
|
+
consentPath: '/oauth/authorize', // a page in your front end (see examples/consent-page.tsx)
|
|
38
|
+
scopes: {
|
|
39
|
+
'notes:read': { description: 'Read your notes', required: true },
|
|
40
|
+
'notes:write': { description: 'Attach AI summaries to notes', default: true },
|
|
41
|
+
},
|
|
42
|
+
identity: sessionIdentity(async (req) => await sessions.userFromCookie(req)), // { id } or null
|
|
43
|
+
storage: sqlStore(env.NOTES_DB), // or memoryStore(), or your own AppServerStore
|
|
44
|
+
origins: { publicOrigin: 'https://notes.example.com', webOrigin: 'https://notes.example.com' },
|
|
45
|
+
tools: [
|
|
46
|
+
{
|
|
47
|
+
name: 'notes_list',
|
|
48
|
+
scope: 'notes:read',
|
|
49
|
+
description: 'List the signed-in user’s notes.',
|
|
50
|
+
inputSchema: {},
|
|
51
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
52
|
+
handler: async (_args, ctx) => ({ content: [{ type: 'text', text: JSON.stringify(await listNotes(ctx.ownerId)) }] }),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'notes_summarize',
|
|
56
|
+
scope: 'notes:write',
|
|
57
|
+
description: 'Store an agent-written summary for one note.',
|
|
58
|
+
inputSchema: { id: z.string(), summary: z.string().max(500) },
|
|
59
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
60
|
+
handler: async ({ id, summary }, ctx) => { /* UPDATE … WHERE owner = ctx.ownerId */ },
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export default {
|
|
66
|
+
async fetch(request: Request, env: Env) {
|
|
67
|
+
await mcp.ensureSchema(); // idempotent; delegates to the store
|
|
68
|
+
return (await mcp.fetch(request)) ?? app.fetch(request, env);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Then connect an agent:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
claude mcp add --transport http notes https://notes.example.com/api/notes/mcp
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Claude Code opens the browser, your consent page shows the requested scopes, the user approves, and the agent receives a token bound to that user. A full runnable host lives in [`examples/cloudflare-worker`](./examples/cloudflare-worker); the same 60 lines back the end-to-end test in [`tests`](./tests).
|
|
80
|
+
|
|
81
|
+
## How it works
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
MCP client ──► GET /.well-known/oauth-protected-resource (RFC 9728)
|
|
85
|
+
──► GET /.well-known/oauth-authorization-server (RFC 8414)
|
|
86
|
+
──► POST <base>/oauth/register (RFC 7591, no token)
|
|
87
|
+
──► GET <base>/oauth/authorize?… ─302─► <webOrigin><consentPath>?…
|
|
88
|
+
│ your login + useAgentConsent()
|
|
89
|
+
▼
|
|
90
|
+
◄─302 code ◄── POST <base>/oauth/approve (host identity, never an agent token)
|
|
91
|
+
──► POST <base>/oauth/token (PKCE) → access_token agt_…, refresh_token agr_…
|
|
92
|
+
──► POST <base>/mcp Authorization: Bearer agt_… → tools/list (scoped) / tools/call
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Every access token is bound to `ownerId` + `clientId` + granted scopes + the audience `publicOrigin + mcpPath`. Tool handlers receive that verified context as `ctx`; nothing in `ctx` ever comes from tool input.
|
|
96
|
+
|
|
97
|
+
## Configuration
|
|
98
|
+
|
|
99
|
+
| Option | Required | Meaning |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| `name` | yes | MCP server name; also used in `server.json`. |
|
|
102
|
+
| `basePath` | yes | Where all gateway routes hang: `<basePath>/mcp`, `<basePath>/mcp/schema`, `<basePath>/oauth/*`. |
|
|
103
|
+
| `scopes` | no | Ordered scope catalogue. `required` scopes are always granted; `default` ones are pre-checked on the consent page. Omit it if your app has no scope model: one implicit required scope is used and consent is a plain allow/deny. |
|
|
104
|
+
| `identity` | yes | `IdentityProvider` — see [Identity](#identity). |
|
|
105
|
+
| `tools` | yes | `AgentTool[]` — see [Tools](#tools). |
|
|
106
|
+
| `storage` | yes | `AppServerStore` — see [Storage](#storage). |
|
|
107
|
+
| `origins` | yes | `{ publicOrigin, webOrigin }` or a function of the request. `publicOrigin` is written into metadata and token audience; `webOrigin` hosts the consent page. |
|
|
108
|
+
| `consentPath` | no | Default `/oauth/authorize`. |
|
|
109
|
+
| `accessTokenDays` / `refreshTokenDays` | no | Default 30 / 90. |
|
|
110
|
+
| `tokenPrefix` | no | Default `agt_`. Lets your own routes recognise agent tokens. |
|
|
111
|
+
| `registrationLimit` | no | `{ limiter, key? }` for `POST /oauth/register`, or `false`. Default `memoryRateLimiter()` — see [Rate limiting](#rate-limiting). |
|
|
112
|
+
| `onEvent` | no | Audit hook: `authorized` / `refreshed` / `revoked` / `tool`. Never receives tool payloads. |
|
|
113
|
+
| `anonymousDiscovery` | no | Default `true`. Let `initialize` / `tools/list` answer without a token. |
|
|
114
|
+
| `contract` | no | Free-text (markdown) data contract published with the schema. |
|
|
115
|
+
|
|
116
|
+
### Identity
|
|
117
|
+
|
|
118
|
+
The only contract between your app and the server. `id` must be stable and never reused; every token is bound to it.
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
import { sessionIdentity, jwtIdentity, firebaseIdentity, fixedIdentity } from '@ninomae/mcp-app-server';
|
|
122
|
+
|
|
123
|
+
sessionIdentity(async (req) => await sessions.userFromCookie(req)); // server-side sessions
|
|
124
|
+
jwtIdentity({ jwksUrl, issuer, audience }); // Supabase, Auth0, Clerk, Cognito, any OIDC
|
|
125
|
+
firebaseIdentity(projectId); // Firebase Authentication preset
|
|
126
|
+
fixedIdentity('local'); // single-user / local dev only
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Or implement `IdentityProvider` yourself: `{ resolve(request) => Promise<{ id, displayName?, email? }> }`, throwing `AppServerError(401, …)` when nobody is signed in.
|
|
130
|
+
|
|
131
|
+
### Storage
|
|
132
|
+
|
|
133
|
+
The server must remember registered clients, single-use authorization codes, refresh-token chains and access tokens (all secrets stored as SHA-256). It does so only through `AppServerStore`, a plain-object repository interface with four collections and two optional hooks:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
interface AppServerStore {
|
|
137
|
+
clients: { create; get; touch };
|
|
138
|
+
codes: { create; get; consume /* atomic, first caller wins */; setIssuedToken };
|
|
139
|
+
refreshTokens: { create; get; getByAccessToken; revoke /* atomic */; revokeByAccessToken; setSuccessor };
|
|
140
|
+
accessTokens: { create; get; getByHash; listByOwner; touch; revoke };
|
|
141
|
+
prune?(now: string): Promise<void>; // delete expired codes / refresh tokens
|
|
142
|
+
ensureSchema?(): Promise<void>; // idempotent setup, surfaced as mcp.ensureSchema()
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Two implementations ship with the package:
|
|
147
|
+
|
|
148
|
+
```ts
|
|
149
|
+
import { memoryStore } from '@ninomae/mcp-app-server'; // Maps; dev, tests, single process
|
|
150
|
+
import { sqlStore } from '@ninomae/mcp-app-server/sql'; // Cloudflare D1 as-is; node:sqlite / better-sqlite3 / libsql with a 10-line wrapper
|
|
151
|
+
sqlStore(env.DB, { clients: 'my_clients' }) // optional table-name overrides
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
To back it with Postgres, Redis, KV, Mongo, Prisma, Drizzle… implement the interface (about 150 lines; [`src/store.ts`](./src/store.ts) is the reference). The two methods marked *atomic* are what the replay defences rely on: `codes.consume` and `refreshTokens.revoke` must return `true` for exactly one caller. Copy [`tests/memory-store.test.mjs`](./tests/memory-store.test.mjs), swap in your store, and the suite checks both.
|
|
155
|
+
|
|
156
|
+
### Rate limiting
|
|
157
|
+
|
|
158
|
+
Dynamic client registration is unauthenticated and writes to storage, so it is rate limited. Like storage, the server defines only the contract:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
interface RateLimiter { allow(key: string): Promise<boolean> } // false → 429
|
|
162
|
+
registrationLimit: { limiter, key?: (request) => string } // key defaults to the client IP
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The default `memoryRateLimiter({ limit: 20, windowMs: 3_600_000 })` counts in process memory, which is correct on one long-lived server and **not** on Workers, Lambda or any multi-instance deployment. There, plug in the platform's shared counter — Cloudflare's rate-limiting binding is three lines:
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
registrationLimit: { limiter: { allow: async (key) => (await env.REGISTER_LIMIT.limit({ key })).success } },
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
`registrationLimit: false` disables it (e.g. behind your own WAF rule).
|
|
172
|
+
|
|
173
|
+
### Tools
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
interface AgentTool {
|
|
177
|
+
name: string;
|
|
178
|
+
scope?: string; // omit for tools every authorised agent may use
|
|
179
|
+
description: string;
|
|
180
|
+
inputSchema: ZodRawShape; // zod v4 shape; published as JSON Schema
|
|
181
|
+
annotations: { readOnlyHint: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint: boolean };
|
|
182
|
+
handler(args, ctx: ToolContext): Promise<{ content: { type: 'text'; text: string }[]; isError?: boolean }>;
|
|
183
|
+
}
|
|
184
|
+
interface ToolContext { ownerId; scopes; grantId; clientId; clientName; request }
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
If your tools are thin wrappers over an existing REST API, forward `ctx.request`'s `Authorization` header to your own handlers and let that layer accept agent tokens via `mcp.authenticate(request)`.
|
|
188
|
+
|
|
189
|
+
### Gateway API
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
mcp.fetch(request) // Response | null — mount first in your router
|
|
193
|
+
mcp.authenticate(request) // AuthenticatedGrant | null — accept agent tokens on your own routes
|
|
194
|
+
mcp.carriesToken(request) // cheap prefix check
|
|
195
|
+
mcp.listGrants(ownerId) // which agents are connected, with what scopes, last used when
|
|
196
|
+
mcp.revokeGrant(ownerId, grantId) // revoke a grant and its whole refresh chain
|
|
197
|
+
mcp.ensureSchema() // idempotent table setup
|
|
198
|
+
mcp.describe(request) // the public schema document
|
|
199
|
+
mcp.serverJson(request, 'io.github.you', 'One-line description') // MCP Registry server.json
|
|
200
|
+
mcp.paths // { mcp, schema, oauth, consent }
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
### Consent page (React)
|
|
204
|
+
|
|
205
|
+
```tsx
|
|
206
|
+
import { useAgentConsent } from '@ninomae/mcp-app-server/react';
|
|
207
|
+
|
|
208
|
+
const { client, chosen, toggle, decide, error, busy, destination, missingClient } = useAgentConsent({
|
|
209
|
+
basePath: '/api/notes',
|
|
210
|
+
authHeaders: () => ({ authorization: 'Bearer ' + session.token }), // or omit and rely on cookies
|
|
211
|
+
});
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
The hook is headless: render your own login and layout, map `client.scopeDetails` to checkboxes (`required` ones disabled), and call `decide('approve' | 'deny')`. See [`examples/consent-page.tsx`](./examples/consent-page.tsx). Non-React front ends can call `GET <base>/oauth/client` and `POST <base>/oauth/approve` directly.
|
|
215
|
+
|
|
216
|
+
## Endpoints
|
|
217
|
+
|
|
218
|
+
| Path | Auth | Purpose |
|
|
219
|
+
| --- | --- | --- |
|
|
220
|
+
| `GET /.well-known/oauth-protected-resource[<mcpPath>]` | none | Resource metadata (RFC 9728) |
|
|
221
|
+
| `GET /.well-known/oauth-authorization-server` | none | Authorization server metadata (RFC 8414) |
|
|
222
|
+
| `GET <base>/mcp/schema` | none | Server name, endpoint, scopes, JSON Schema of every tool |
|
|
223
|
+
| `POST <base>/mcp` (`initialize` / `ping` / `tools/list`) | none | Anonymous discovery; full tool list |
|
|
224
|
+
| `POST <base>/mcp` (anything else) | agent token | MCP Streamable HTTP |
|
|
225
|
+
| `POST <base>/oauth/register` | none (rate-limited via `registrationLimit`) | Dynamic client registration |
|
|
226
|
+
| `GET <base>/oauth/authorize` | none | Validate, then 302 to the consent page |
|
|
227
|
+
| `GET <base>/oauth/client` | none | Client + scope descriptions for the consent page |
|
|
228
|
+
| `POST <base>/oauth/approve` | **host identity** | User approves/denies → authorization code |
|
|
229
|
+
| `POST <base>/oauth/token` | client | Code exchange, refresh rotation |
|
|
230
|
+
|
|
231
|
+
## What the server does not do
|
|
232
|
+
|
|
233
|
+
It is a bridge, not an authorization system. It never decides whether a user may see a record: `ctx.ownerId` is the verified user, and your handler enforces your existing rules exactly as it would for a browser session (call your service layer with that id, or re-enter your own REST API with `ctx.request`'s `Authorization` header and let `mcp.authenticate()` identify the user there). Scopes are consent, not permissions — they narrow what *this agent* may do on the user's behalf, and you can skip them entirely.
|
|
234
|
+
|
|
235
|
+
## Security model
|
|
236
|
+
|
|
237
|
+
- User identity comes only from `identity.resolve`; an agent token can never approve a grant.
|
|
238
|
+
- Authorization codes live 10 minutes and are single-use; replaying one revokes the tokens it issued. Both rely on the store's atomic `consume` / `revoke`.
|
|
239
|
+
- Refresh tokens rotate; replaying a rotated refresh token cuts the whole chain.
|
|
240
|
+
- Tokens are stored as SHA-256 only. Audience is `publicOrigin + mcpPath`, so moving domains invalidates old tokens.
|
|
241
|
+
- `tools/call` always requires a token; anonymous discovery exposes tool metadata, never data.
|
|
242
|
+
- Cross-site `Origin` headers on MCP requests are rejected (loopback excepted).
|
|
243
|
+
- Redirect URIs are classified as `loopback` / `custom` / `https`; anything else is refused at registration.
|
|
244
|
+
|
|
245
|
+
## Compared with Cloudflare `workers-oauth-provider`
|
|
246
|
+
|
|
247
|
+
It solves the same OAuth layer with KV storage. This package adds: pluggable storage (any database, D1 included), pluggable host identity, a scoped tool table, grant-trimmed `tools/list`, a public schema with anonymous discovery, a consent-page hook, grant management APIs and a reusable end-to-end test. If you only need OAuth and none of the above, use the official library.
|
|
248
|
+
|
|
249
|
+
## Development
|
|
250
|
+
|
|
251
|
+
```bash
|
|
252
|
+
npm install
|
|
253
|
+
npm run typecheck
|
|
254
|
+
npm test # Miniflare + D1 end-to-end, memoryStore replay/revocation rules, node:sqlite adapter
|
|
255
|
+
npm run build # emits dist/ (ESM + .d.ts)
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) and [docs/publishing.md](./docs/publishing.md).
|
|
259
|
+
|
|
260
|
+
## License
|
|
261
|
+
|
|
262
|
+
[MIT](./LICENSE)
|
package/README.zh-CN.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# @ninomae/mcp-app-server
|
|
2
|
+
|
|
3
|
+
**把你已有的应用变成一个带 OAuth 2.1 授权的 MCP server。**
|
|
4
|
+
|
|
5
|
+
[English](./README.md) · [接入指南](./docs/integration.md) · [发布流程](./docs/publishing.md) · [变更记录](./CHANGELOG.md)
|
|
6
|
+
|
|
7
|
+
把**你已有的用户和数据**,以标准 **MCP + OAuth 2.1** 的方式授权给外部 AI Agent(Claude、ChatGPT、Cursor、Claude Code、任何 MCP 客户端)。你不需要自己接 AI 模型:用户带着自己的 Agent 来,读你的数据、在他们那边分析、再通过写工具把结果送回你的应用。
|
|
8
|
+
|
|
9
|
+
- **身份不绑定任何厂商。** 你的应用已经有登录(Firebase / Supabase / Auth0 / Clerk / session cookie / 自研)——只需实现一个 `resolve(request) → { id }`。
|
|
10
|
+
- **框架自己当 OAuth 授权服务器。** MCP 客户端要求的动态注册(RFC 7591)、PKCE、资源指示(RFC 8707)、refresh 轮换、按 Agent 撤销,消费级 IdP 普遍不提供,所以这一层必须在你手里。
|
|
11
|
+
- **工具表 = 你的产品的 Agent 面。** 每个工具声明所需 scope,`tools/list` 按用户实际授权裁剪。
|
|
12
|
+
- **公开可发现。** `/.well-known/*`、`GET <basePath>/mcp/schema`、匿名 `initialize` / `tools/list` 都不需要 token,可直接提交官方 MCP Registry / Claude 连接器目录。
|
|
13
|
+
- **不绑定数据库。** 核心不依赖任何数据库实现;实现一个很小的 `AppServerStore` 接口就能接 SQL、KV、Redis、Mongo 或任意 ORM。包内自带 `memoryStore()`(开发/测试)和 `sqlStore()`(Cloudflare D1、各种 SQLite 驱动)。
|
|
14
|
+
|
|
15
|
+
## 安装
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @ninomae/mcp-app-server @modelcontextprotocol/sdk zod
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
可选 peer:`jose`(用 `jwtIdentity` / `firebaseIdentity` 时需要)、`react`(用 `/react` 同意页 hook 时需要)。Node ≥ 22 或任何 Web 标准运行时(Cloudflare Workers、Deno、Bun)。
|
|
22
|
+
|
|
23
|
+
## 接入七步
|
|
24
|
+
|
|
25
|
+
### 1. 决定 scope
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
scopes: {
|
|
29
|
+
'notes:read': { description: '阅读你的笔记', required: true }, // 锁定,永远授予
|
|
30
|
+
'notes:write': { description: '为笔记附加 AI 摘要', default: true }, // 同意页默认勾选
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`description` 会通过 `GET <basePath>/oauth/client` 交给同意页显示。读一个、写一个起步;写 scope 只允许 Agent 写**它自己的产出物**,不要让它改用户主数据。
|
|
35
|
+
|
|
36
|
+
### 2. 实现身份
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { sessionIdentity, jwtIdentity, firebaseIdentity, fixedIdentity } from '@ninomae/mcp-app-server';
|
|
40
|
+
|
|
41
|
+
// 服务端 session(NextAuth、Rails、Django、Laravel、自研 cookie)
|
|
42
|
+
identity: sessionIdentity(async (req) => await mySessions.userFromCookie(req)) // 返回 { id } 或 null
|
|
43
|
+
|
|
44
|
+
// 前端持有 JWT(Supabase、Auth0、Clerk、Cognito……任何 OIDC)
|
|
45
|
+
identity: jwtIdentity({ jwksUrl, issuer, audience })
|
|
46
|
+
|
|
47
|
+
// Firebase 预设
|
|
48
|
+
identity: firebaseIdentity(env.FIREBASE_PROJECT_ID)
|
|
49
|
+
|
|
50
|
+
// 单用户 / 本机开发(切勿放到公网 origin 后面)
|
|
51
|
+
identity: fixedIdentity('local')
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
契约只有一条:`id` 必须稳定、不可重用。之后每个 Agent token 都绑定它,工具 handler 拿到的 `ctx.ownerId` 就是它。
|
|
55
|
+
|
|
56
|
+
### 3. 定义工具
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
import { z } from 'zod';
|
|
60
|
+
|
|
61
|
+
tools: [
|
|
62
|
+
{
|
|
63
|
+
name: 'notes_list',
|
|
64
|
+
scope: 'notes:read',
|
|
65
|
+
description: '列出当前用户的笔记。',
|
|
66
|
+
inputSchema: {},
|
|
67
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
68
|
+
handler: async (_args, ctx) => ({ content: [{ type: 'text', text: JSON.stringify(await listNotes(ctx.ownerId)) }] }),
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
name: 'notes_summarize',
|
|
72
|
+
scope: 'notes:write',
|
|
73
|
+
description: '为一条笔记保存 Agent 写的摘要。只写摘要,不改正文。',
|
|
74
|
+
inputSchema: { id: z.string(), summary: z.string().max(500) },
|
|
75
|
+
annotations: { readOnlyHint: false, destructiveHint: false, openWorldHint: false },
|
|
76
|
+
handler: async ({ id, summary }, ctx) => { /* UPDATE … WHERE owner = ctx.ownerId */ },
|
|
77
|
+
},
|
|
78
|
+
]
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
`ctx` 里有 `ownerId / scopes / grantId / clientId / clientName / request`——全部来自已验证的 token,绝不来自工具输入。如果你的工具只是已有 REST API 的薄包装,可以用 `ctx.request` 的 `Authorization` 头把请求转回自己的 handler,并用 `mcp.authenticate(request)` 让 REST 层接受 Agent token。
|
|
82
|
+
|
|
83
|
+
### 4. 创建 server 并挂路由
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { createMcpAppServer } from '@ninomae/mcp-app-server';
|
|
87
|
+
import { sqlStore } from '@ninomae/mcp-app-server/sql';
|
|
88
|
+
|
|
89
|
+
const mcp = createMcpAppServer({
|
|
90
|
+
name: 'notes',
|
|
91
|
+
basePath: '/api/notes', // → /api/notes/mcp, /api/notes/mcp/schema, /api/notes/oauth/*
|
|
92
|
+
consentPath: '/oauth/authorize', // 你的前端页面
|
|
93
|
+
scopes, identity, tools,
|
|
94
|
+
storage: sqlStore(env.NOTES_DB), // 或 memoryStore(),或你自己实现的 AppServerStore
|
|
95
|
+
origins: { publicOrigin: 'https://notes.example.com', webOrigin: 'https://notes.example.com' },
|
|
96
|
+
onEvent: (e) => audit.write(e), // authorized / refreshed / revoked / tool(不含 payload)
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
export default {
|
|
100
|
+
async fetch(request, env) {
|
|
101
|
+
await mcp.ensureSchema(); // 幂等,转发给 store
|
|
102
|
+
const handled = await mcp.fetch(request);
|
|
103
|
+
if (handled) return handled; // well-known / oauth / mcp / schema
|
|
104
|
+
// …你原有的路由
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
**存储。** 本包需要记住注册过的客户端、一次性授权码、refresh token 链和 access token(密钥一律只存 SHA-256)。它只通过 `AppServerStore` 这个普通对象仓储接口访问它们:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
interface AppServerStore {
|
|
113
|
+
clients: { create; get; touch };
|
|
114
|
+
codes: { create; get; consume /* 原子,只有第一个调用者得到 true */; setIssuedToken };
|
|
115
|
+
refreshTokens: { create; get; getByAccessToken; revoke /* 原子 */; revokeByAccessToken; setSuccessor };
|
|
116
|
+
accessTokens: { create; get; getByHash; listByOwner; touch; revoke };
|
|
117
|
+
prune?(now): Promise<void>; // 可选:清理过期 code / refresh token
|
|
118
|
+
ensureSchema?(): Promise<void>; // 可选:幂等建表,对应 mcp.ensureSchema()
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
自带两个实现:`memoryStore()`(Map,开发/测试/单进程)和 `@ninomae/mcp-app-server/sql` 里的 `sqlStore(db, tables?)`(D1 直接传,node:sqlite / better-sqlite3 / libsql 包十行适配器)。要接 Postgres、Redis、KV、Mongo、Prisma、Drizzle,就自己实现这个接口(约 150 行,[`src/store.ts`](./src/store.ts) 是参考实现)。标"原子"的两个方法是防重放的关键:`codes.consume` 和 `refreshTokens.revoke` 必须恰好对一个调用者返回 `true`。把 [`tests/memory-store.test.mjs`](./tests/memory-store.test.mjs) 复制一份、换成你的 store,就能验证。
|
|
123
|
+
|
|
124
|
+
**注册限流。** `POST /oauth/register` 无需认证但会写存储,所以有限流。和存储一样只定义契约:`registrationLimit: { limiter: { allow(key) => Promise<boolean> }, key?: (request) => string }`,`false` 关闭。默认 `memoryRateLimiter()`(每 IP 每小时 20 次,进程内计数)——单进程服务器正确,**Workers / Lambda 等多实例环境无效**,请接平台的共享计数器,例如 Cloudflare 的 rate-limiting binding:`{ limiter: { allow: async (key) => (await env.REGISTER_LIMIT.limit({ key })).success } }`。
|
|
125
|
+
|
|
126
|
+
其他可选项:`accessTokenDays`(默认 30)、`refreshTokenDays`(默认 90)、`tokenPrefix`(默认 `agt_`,用于在自己的路由里识别 Agent token)、`anonymousDiscovery`(默认 true)、`contract`(随 schema 一起发布的数据契约 markdown)。
|
|
127
|
+
|
|
128
|
+
### 5. 同意页
|
|
129
|
+
|
|
130
|
+
授权服务器会把用户 302 到 `webOrigin + consentPath`(原样带 query)。页面用你自己的登录,用 headless hook 完成其余部分:
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
import { useAgentConsent } from '@ninomae/mcp-app-server/react';
|
|
134
|
+
|
|
135
|
+
const { client, chosen, toggle, decide, error, busy, destination, missingClient } = useAgentConsent({
|
|
136
|
+
basePath: '/api/notes',
|
|
137
|
+
authHeaders: () => ({ authorization: 'Bearer ' + session.token }), // 或留空,靠 cookie
|
|
138
|
+
});
|
|
139
|
+
// client.scopeDetails → 渲染复选框(required 的禁用);decide('approve' | 'deny') 会跳回 Agent
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
完整示例见 [`examples/consent-page.tsx`](./examples/consent-page.tsx)。非 React 前端直接调 `GET <base>/oauth/client` 和 `POST <base>/oauth/approve` 即可。
|
|
143
|
+
|
|
144
|
+
### 6. 让用户管理授权
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
mcp.listGrants(ownerId) // 哪些 Agent 连着我、什么权限、最后使用时间
|
|
148
|
+
mcp.revokeGrant(ownerId, grantId) // 撤销并切断整条 refresh 链
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### 7. 验证与发布
|
|
152
|
+
|
|
153
|
+
- `npm test` 里的 Miniflare 端到端(注册 → 授权 → 同意 → 换 token → 调工具 → 刷新 → 撤销)就是模板;fixture 在 `tests/fixtures/notes-host.ts`,60 行就是一个完整宿主。
|
|
154
|
+
- 真连一次:`claude mcp add --transport http notes https://notes.example.com/api/notes/mcp`,然后在 Claude Code 里 `/mcp` → Authenticate。
|
|
155
|
+
- `mcp.serverJson(request, 'io.github.<you>', '一句话描述')` 生成官方 MCP Registry 的 `server.json`;`mcp-publisher publish` 即可上架。Claude / ChatGPT 连接器目录另需人工提交(固定 HTTPS 地址、隐私政策)。
|
|
156
|
+
|
|
157
|
+
各类客户端(Claude Code、Claude.ai、ChatGPT、Cursor、自写脚本)的具体接入步骤见 [docs/integration.md](./docs/integration.md)。
|
|
158
|
+
|
|
159
|
+
## 端点一览
|
|
160
|
+
|
|
161
|
+
| 路径 | 认证 | 作用 |
|
|
162
|
+
| --- | --- | --- |
|
|
163
|
+
| `GET /.well-known/oauth-protected-resource[<mcpPath>]` | 无 | 资源元数据(RFC 9728) |
|
|
164
|
+
| `GET /.well-known/oauth-authorization-server` | 无 | 授权服务器元数据(RFC 8414) |
|
|
165
|
+
| `GET <base>/mcp/schema` | 无 | 服务器名、端点、scope、全部工具的 JSON Schema |
|
|
166
|
+
| `POST <base>/mcp`(`initialize`/`ping`/`tools/list`) | 无 | 匿名发现;返回全量工具 |
|
|
167
|
+
| `POST <base>/mcp`(其他) | Agent token | MCP Streamable HTTP |
|
|
168
|
+
| `POST <base>/oauth/register` | 无(限速) | 动态客户端注册 |
|
|
169
|
+
| `GET <base>/oauth/authorize` | 无 | 校验后 302 到同意页 |
|
|
170
|
+
| `GET <base>/oauth/client` | 无 | 同意页读取客户端与 scope 说明 |
|
|
171
|
+
| `POST <base>/oauth/approve` | **宿主身份** | 用户同意/拒绝 → 授权码 |
|
|
172
|
+
| `POST <base>/oauth/token` | 客户端 | 授权码换 token、refresh 轮换 |
|
|
173
|
+
|
|
174
|
+
## 本包不做什么
|
|
175
|
+
|
|
176
|
+
它是桥接,不是权限系统。它从不判断某个用户能不能看某条记录:`ctx.ownerId` 是验证过的用户,你的 handler 用它执行**原有**的规则——直接调你的 service 层传这个 id,或者带上 `ctx.request` 的 `Authorization` 头回调你自己的 REST API,在那里用 `mcp.authenticate()` 认出用户,其余逻辑和浏览器会话一模一样。scope 是"授权委托"而不是"权限":它只收窄*这个 agent* 能替用户做的事,`scopes` 可以完全省略(本包会用一个隐式 scope,同意页变成单纯的允许/拒绝)。
|
|
177
|
+
|
|
178
|
+
## 安全边界
|
|
179
|
+
|
|
180
|
+
- 用户身份只来自 `identity.resolve`;Agent token 不能用来 approve。
|
|
181
|
+
- 授权码 10 分钟、一次性;重放会吊销它签发的 token。refresh 轮换;重放已轮换的 refresh 会切断整条链。
|
|
182
|
+
- token 只存 SHA-256;audience 绑定 `publicOrigin + mcpPath`,换域名后旧 token 自动失效。
|
|
183
|
+
- `tools/call` 永远要 token;匿名发现只暴露工具元数据,不暴露数据。
|
|
184
|
+
- 跨站 `Origin` 的 MCP 请求被拒绝(loopback 除外)。
|
|
185
|
+
- redirect_uri 只接受 loopback / 自定义 scheme / https,注册时即拒绝其他形式。
|
|
186
|
+
|
|
187
|
+
## 与 Cloudflare `workers-oauth-provider` 的区别
|
|
188
|
+
|
|
189
|
+
它解决同一层的 OAuth 问题(KV 存储)。本包多出:可插拔存储(任意数据库,含 D1)、可插拔宿主身份、scope 化工具表、按授权裁剪的 `tools/list`、公开 schema 与匿名发现、同意页 hook、授权管理 API、以及一套可复用的端到端测试。如果你只需要 OAuth 而不需要以上任何一项,用官方库即可。
|
|
190
|
+
|
|
191
|
+
## 开发
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
npm install
|
|
195
|
+
npm run typecheck
|
|
196
|
+
npm test # Miniflare 端到端
|
|
197
|
+
npm run build # 产出 dist/(ESM + .d.ts)
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
发布到 npm 的完整步骤见 [docs/publishing.md](./docs/publishing.md)。
|
|
201
|
+
|
|
202
|
+
## 许可
|
|
203
|
+
|
|
204
|
+
[MIT](./LICENSE)
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Identity, IdentityProvider } from './types.js';
|
|
2
|
+
export interface JwtIdentityOptions {
|
|
3
|
+
/** JWKS endpoint of the issuer (any OIDC provider publishes one). */
|
|
4
|
+
jwksUrl: string;
|
|
5
|
+
issuer: string;
|
|
6
|
+
audience: string;
|
|
7
|
+
/** Claim that carries the stable user id. Default `sub`. */
|
|
8
|
+
subjectClaim?: string;
|
|
9
|
+
algorithms?: string[];
|
|
10
|
+
/** Extra checks on the verified payload; throw AppServerError to reject. */
|
|
11
|
+
assert?: (payload: Record<string, unknown>) => void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* The host front end holds a JWT for the signed-in user (Firebase, Supabase, Auth0, Clerk, Cognito…)
|
|
15
|
+
* and sends it as `Authorization: Bearer` when approving. Requires the optional `jose` peer dependency.
|
|
16
|
+
*/
|
|
17
|
+
export declare function jwtIdentity(options: JwtIdentityOptions): IdentityProvider;
|
|
18
|
+
/** Firebase Authentication preset over jwtIdentity. */
|
|
19
|
+
export declare function firebaseIdentity(projectId: string, options?: Pick<JwtIdentityOptions, 'assert'>): IdentityProvider;
|
|
20
|
+
/**
|
|
21
|
+
* The host keeps a server-side session (cookie, framework session store). Supply the lookup;
|
|
22
|
+
* return null when nobody is signed in.
|
|
23
|
+
*/
|
|
24
|
+
export declare function sessionIdentity(lookup: (request: Request) => Promise<Identity | null>): IdentityProvider;
|
|
25
|
+
/** Single-user / local development: every consent is attributed to one fixed id. Never use behind a public origin. */
|
|
26
|
+
export declare function fixedIdentity(id: string, displayName?: string): IdentityProvider;
|
|
27
|
+
//# sourceMappingURL=identity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity.d.ts","sourceRoot":"","sources":["../src/identity.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,QAAQ,EAAE,gBAAgB,EAAE,MAAM,YAAY,CAAC;AAU7D,MAAM,WAAW,kBAAkB;IACjC,qEAAqE;IACrE,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,4DAA4D;IAC5D,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,UAAU,CAAC,EAAE,MAAM,EAAE,CAAC;IACtB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,IAAI,CAAC;CACrD;AAED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,kBAAkB,GAAG,gBAAgB,CAmBzE;AAED,uDAAuD;AACvD,wBAAgB,gBAAgB,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,GAAE,IAAI,CAAC,kBAAkB,EAAE,QAAQ,CAAM,GAAG,gBAAgB,CAOtH;AAED;;;GAGG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,GAAG,gBAAgB,CAQxG;AAED,sHAAsH;AACtH,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,WAAW,SAAoB,GAAG,gBAAgB,CAE3F"}
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { AppServerError } from './types.js';
|
|
2
|
+
function bearer(request) {
|
|
3
|
+
const header = request.headers.get('authorization') || '';
|
|
4
|
+
const token = header.replace(/^Bearer\s+/i, '').trim();
|
|
5
|
+
if (!header.startsWith('Bearer ') || !token)
|
|
6
|
+
throw new AppServerError(401, 'Sign in first');
|
|
7
|
+
return token;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The host front end holds a JWT for the signed-in user (Firebase, Supabase, Auth0, Clerk, Cognito…)
|
|
11
|
+
* and sends it as `Authorization: Bearer` when approving. Requires the optional `jose` peer dependency.
|
|
12
|
+
*/
|
|
13
|
+
export function jwtIdentity(options) {
|
|
14
|
+
let keys;
|
|
15
|
+
return {
|
|
16
|
+
async resolve(request) {
|
|
17
|
+
const token = bearer(request);
|
|
18
|
+
const jose = await import('jose');
|
|
19
|
+
keys ??= jose.createRemoteJWKSet(new URL(options.jwksUrl));
|
|
20
|
+
let payload;
|
|
21
|
+
try {
|
|
22
|
+
payload = (await jose.jwtVerify(token, keys, { issuer: options.issuer, audience: options.audience, algorithms: options.algorithms ?? ['RS256'] })).payload;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
throw new AppServerError(401, 'Sign-in expired; sign in again');
|
|
26
|
+
}
|
|
27
|
+
options.assert?.(payload);
|
|
28
|
+
const id = payload[options.subjectClaim ?? 'sub'];
|
|
29
|
+
if (typeof id !== 'string' || !id)
|
|
30
|
+
throw new AppServerError(401, 'Token missing subject');
|
|
31
|
+
return { id, email: typeof payload.email === 'string' ? payload.email : undefined, displayName: typeof payload.name === 'string' ? payload.name : undefined };
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** Firebase Authentication preset over jwtIdentity. */
|
|
36
|
+
export function firebaseIdentity(projectId, options = {}) {
|
|
37
|
+
return jwtIdentity({
|
|
38
|
+
jwksUrl: 'https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com',
|
|
39
|
+
issuer: `https://securetoken.google.com/${projectId}`,
|
|
40
|
+
audience: projectId,
|
|
41
|
+
...options,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* The host keeps a server-side session (cookie, framework session store). Supply the lookup;
|
|
46
|
+
* return null when nobody is signed in.
|
|
47
|
+
*/
|
|
48
|
+
export function sessionIdentity(lookup) {
|
|
49
|
+
return {
|
|
50
|
+
async resolve(request) {
|
|
51
|
+
const identity = await lookup(request);
|
|
52
|
+
if (!identity)
|
|
53
|
+
throw new AppServerError(401, 'Sign in first');
|
|
54
|
+
return identity;
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Single-user / local development: every consent is attributed to one fixed id. Never use behind a public origin. */
|
|
59
|
+
export function fixedIdentity(id, displayName = 'Local workspace') {
|
|
60
|
+
return { resolve: async () => ({ id, displayName }) };
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=identity.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity.js","sourceRoot":"","sources":["../src/identity.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { AuthenticatedGrant, McpAppServerConfig, Grant, Origins } from './types.js';
|
|
2
|
+
import { describeServer, registryEntry } from './mcp.js';
|
|
3
|
+
export * from './types.js';
|
|
4
|
+
export * from './store.js';
|
|
5
|
+
export * from './rate-limit.js';
|
|
6
|
+
export * from './identity.js';
|
|
7
|
+
export { classifyRedirect } from './oauth.js';
|
|
8
|
+
export interface McpAppServer {
|
|
9
|
+
/** Handle a request if it belongs to the server; returns null for every other path. */
|
|
10
|
+
fetch(request: Request): Promise<Response | null>;
|
|
11
|
+
/** Verify an agent access token carried by any request (e.g. the host's own REST routes). Null when the request carries no server-issued token. */
|
|
12
|
+
authenticate(request: Request): Promise<AuthenticatedGrant | null>;
|
|
13
|
+
/** True when the bearer token on this request looks like a server-issued token. */
|
|
14
|
+
carriesToken(request: Request): boolean;
|
|
15
|
+
listGrants(ownerId: string): Promise<Grant[]>;
|
|
16
|
+
/** Revoke one grant and every token rotated from it. Throws 404 when the owner does not hold it. */
|
|
17
|
+
revokeGrant(ownerId: string, grantId: string): Promise<void>;
|
|
18
|
+
/** Delegates to `storage.ensureSchema()` when the store defines one; otherwise a no-op. */
|
|
19
|
+
ensureSchema(): Promise<void>;
|
|
20
|
+
/** Public schema document (same as GET <basePath>/mcp/schema). */
|
|
21
|
+
describe(request: Request): ReturnType<typeof describeServer>;
|
|
22
|
+
/** `server.json` for the official MCP registry. */
|
|
23
|
+
serverJson(request: Request, namespace: string, description: string): ReturnType<typeof registryEntry>;
|
|
24
|
+
origins(request: Request): Origins;
|
|
25
|
+
paths: {
|
|
26
|
+
mcp: string;
|
|
27
|
+
schema: string;
|
|
28
|
+
oauth: string;
|
|
29
|
+
consent: string;
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export declare function createMcpAppServer(config: McpAppServerConfig): McpAppServer;
|
|
33
|
+
//# sourceMappingURL=index.d.ts.map
|