@escalay/kit 0.0.0-stage → 1.7.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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -2
  3. package/cli.mjs +432 -0
  4. package/docs/STANDARD.md +342 -0
  5. package/docs/decisions/0001-features-drizzle-scaffold.md +35 -0
  6. package/docs/decisions/0002-node-and-workers.md +45 -0
  7. package/package.json +58 -3
  8. package/registry.json +512 -0
  9. package/src/a2a/README.md +25 -0
  10. package/src/a2a/index.ts +262 -0
  11. package/src/boards/README.md +35 -0
  12. package/src/boards/handlers.ts +87 -0
  13. package/src/boards/index.ts +64 -0
  14. package/src/boards/refs.ts +23 -0
  15. package/src/boards/repo.ts +50 -0
  16. package/src/boards/routes.ts +83 -0
  17. package/src/boards/schema.ts +28 -0
  18. package/src/boards/tools.ts +76 -0
  19. package/src/browser/README.md +31 -0
  20. package/src/browser/boards-client.ts +74 -0
  21. package/src/browser/boards-react.ts +73 -0
  22. package/src/browser/search-client.ts +258 -0
  23. package/src/browser/search-react.ts +116 -0
  24. package/src/browser/vector-search-block.tsx +94 -0
  25. package/src/core/README.md +37 -0
  26. package/src/core/api.ts +239 -0
  27. package/src/core/auth.ts +147 -0
  28. package/src/core/cache.ts +53 -0
  29. package/src/core/discovery.ts +315 -0
  30. package/src/core/guards.ts +109 -0
  31. package/src/core/hal.ts +63 -0
  32. package/src/core/kv.ts +29 -0
  33. package/src/core/problem.ts +124 -0
  34. package/src/db/README.md +39 -0
  35. package/src/db/guards.ts +61 -0
  36. package/src/db/index.ts +122 -0
  37. package/src/guide/README.md +38 -0
  38. package/src/guide/guide.ts +152 -0
  39. package/src/guide/reference.ts +204 -0
  40. package/src/index.ts +15 -0
  41. package/src/mcp/README.md +34 -0
  42. package/src/mcp/index.ts +251 -0
  43. package/src/oauth/README.md +29 -0
  44. package/src/oauth/index.ts +439 -0
  45. package/src/oauth/schema.ts +7 -0
  46. package/src/oauth/store.ts +34 -0
  47. package/src/pagination/README.md +31 -0
  48. package/src/pagination/index.ts +65 -0
  49. package/src/testing/README.md +31 -0
  50. package/src/testing/index.ts +162 -0
  51. package/src/vector-search/README.md +27 -0
  52. package/src/vector-search/index.ts +367 -0
  53. package/src/vectors/README.md +30 -0
  54. package/src/vectors/index.ts +326 -0
  55. package/src/web/README.md +21 -0
  56. package/src/web/index.ts +100 -0
  57. package/templates/app/AGENTS.md +41 -0
  58. package/templates/app/CLAUDE.md +3 -0
  59. package/templates/app/Dockerfile +12 -0
  60. package/templates/app/container/entrypoint.sh +7 -0
  61. package/templates/app/gitignore +4 -0
  62. package/templates/app/guide/llms.txt +11 -0
  63. package/templates/app/pnpm-workspace.yaml +5 -0
  64. package/templates/app/scripts/guide.ts +8 -0
  65. package/templates/app/src/app.ts +39 -0
  66. package/templates/app/src/features/index.ts +30 -0
  67. package/templates/app/src/server.ts +14 -0
  68. package/templates/app/test/api.test.ts +34 -0
  69. package/templates/app/tsconfig.json +16 -0
  70. package/templates/feature/feature.test.ts +19 -0
  71. package/templates/feature/handlers.ts +43 -0
  72. package/templates/feature/index.ts +34 -0
  73. package/templates/feature/repo.ts +24 -0
  74. package/templates/feature/routes.ts +43 -0
  75. package/templates/feature/schema.ts +14 -0
  76. package/templates/feature/tools.ts +46 -0
  77. package/templates/runtime-cloudflare/dev.vars.example +3 -0
  78. package/templates/runtime-cloudflare/scripts/migrations.ts +43 -0
  79. package/templates/runtime-cloudflare/src/worker.ts +19 -0
  80. package/templates/runtime-cloudflare/test/workers/api.test.ts +37 -0
  81. package/templates/runtime-cloudflare/test/workers/apply-migrations.ts +5 -0
  82. package/templates/runtime-cloudflare/test/workers/env.d.ts +11 -0
  83. package/templates/runtime-cloudflare/test/workers/key.ts +2 -0
  84. package/templates/runtime-cloudflare/test/workers/tsconfig.json +7 -0
  85. package/templates/runtime-cloudflare/vitest.config.ts +23 -0
  86. package/templates/runtime-cloudflare/wrangler.jsonc +16 -0
@@ -0,0 +1,34 @@
1
+ # mcp
2
+
3
+ An MCP server over the routes: each tool is a thin call to a route, run in-process with the caller's own credentials,
4
+ so the API and MCP can't drift. It also serves resources, prompts, skills and the Server Card.
5
+
6
+ Needs: `core`, `@modelcontextprotocol/server`.
7
+
8
+ ```ts
9
+ import { inProcess, mcpServerFactory, mcpTool, mountMcp } from './kit/mcp/index.js'
10
+
11
+ const getNote = mcpTool({
12
+ name: 'get_note', title: 'One note', description: 'A note with its author, year and tags. Use after a search.',
13
+ input: z.object({ slug: z.string().describe('From a search result.') }),
14
+ annotations: { readOnlyHint: true, openWorldHint: false },
15
+ call: ({ slug }) => ({ path: `/api/notes/${encodeURIComponent(slug)}` }),
16
+ summarize: (note) => `${note.title} (${note.year}) · ${note._links.self.href}`,
17
+ })
18
+ mountMcp(app, { factory: mcpServerFactory({ info, instructions, tools: [getNote], callApi: inProcess(app) }), allowedHostnames: [host, 'localhost'] })
19
+ ```
20
+
21
+ | Export | What |
22
+ |---|---|
23
+ | `mcpTool`, `mcpResource` | a tool (route call + summary) and an attachable resource |
24
+ | `routeOf`, `ROUTE_META` | a tool's route (`GET /api/v1/recipes/{id}`), published in `tools/list` as `_meta["api-kit/route"]`; set `route` on a tool whose path is built in steps |
25
+ | `mcpServerFactory` | the server: info, instructions, tools, resources, prompts, skills; `toolsTtlMs` sets how long clients cache `tools/list` (default an hour) |
26
+ | `mountMcp` | `/mcp` (Streamable HTTP, MCP 2026-07-28 with a fallback for older clients) and the Server Card |
27
+ | `inProcess(app)` / `overHttp(origin, key)` | how tools reach the routes: in the same process, or over HTTP for a stdio entry |
28
+ | `callWaiting` | follows a 202 run until it finishes, so a slow route still answers the tool |
29
+
30
+ ## Rules
31
+
32
+ - One tool per task-sized route, `snake_case` verbs, a description that says when to use it; arguments named like the
33
+ route's parameters. The summary is what an agent reads first: a few lines per result with ids and links.
34
+ - Every tool runs in the app's tests: `toolsRun` (testing module). Standard: `docs/STANDARD.md` §11.
@@ -0,0 +1,251 @@
1
+ /**
2
+ * An MCP server (spec 2026-07-28, stateless Streamable HTTP; 2025-era clients served by the SDK's fallback) whose tools
3
+ * are thin calls to the app's own HTTP routes, so the API and MCP can never disagree: same validation, same auth,
4
+ * same problem details. A tool answers with a short text an agent reads first, the route's JSON as
5
+ * `structuredContent` (matching `outputSchema` when given), and `resource_link`s for images. The skills folder is
6
+ * served through the Skills extension (SEP-2640) and each playbook also as a prompt.
7
+ *
8
+ * @example
9
+ * const tools = [mcpTool({ name: 'search_recipes', title: 'Search recipes', description: '…', input: z.object({ q: z.string() }),
10
+ * call: ({ q }) => ({ path: '/api/v1/search/recipes', query: { q } }), summarize: (page) => … })]
11
+ * mountMcp(app, { factory: mcpServerFactory({ info, instructions, tools, callApi: inProcess(app) }), allowedHostnames: ['cookbook.example.com'] })
12
+ * // a stdio client on a laptop: the same tools over HTTP with its own key
13
+ * serveStdio(mcpServerFactory({ info, instructions, tools, callApi: overHttp(origin, process.env.COOKBOOK_API_KEY!) }))
14
+ */
15
+ import { createMcpHandler, McpServer, originValidationResponse, ResourceTemplate, SUPPORTED_PROTOCOL_VERSIONS, type AuthInfo, type CallToolResult, type McpServerFactory } from '@modelcontextprotocol/server'
16
+ import type { SkillCatalog } from '@modelcontextprotocol/server/ext/skills'
17
+ import type { z } from '@hono/zod-openapi'
18
+ import type { Context, Hono } from 'hono'
19
+ import { MODERN_PROTOCOL_VERSION, recordMounted, sendPublic, serverCard, SERVER_CARD_TYPE, WELL_KNOWN } from '../core/discovery.js'
20
+ import { originOf, pathWith } from '../core/hal.js'
21
+
22
+ type Query = Record<string, string | number | boolean | string[] | null | undefined>
23
+ export type ApiCall = { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; path: string; query?: Query; body?: unknown }
24
+ export type CallApi = (call: ApiCall, auth: AuthInfo | undefined) => Promise<Response>
25
+
26
+ export type ToolAnnotations = { readOnlyHint?: boolean; destructiveHint?: boolean; idempotentHint?: boolean; openWorldHint?: boolean }
27
+ export type ResourceLinkOut = { uri: string; name: string; title?: string; description?: string; mimeType?: string }
28
+
29
+ export type McpTool<Input extends z.ZodObject = z.ZodObject> = {
30
+ /** `snake_case`, 1–64 characters of `a-z0-9_`. */
31
+ name: string
32
+ title: string
33
+ /** When to use it and what comes back — the model chooses tools by this. */
34
+ description: string
35
+ input: Input
36
+ /** The route's 200 schema, when its shape is fixed: the JSON (always sent as `structuredContent`) must match it. */
37
+ output?: z.ZodTypeAny
38
+ annotations?: ToolAnnotations
39
+ call: (args: z.infer<Input>) => ApiCall
40
+ /** The text an agent reads first: a few lines per result with ids and the links to follow. */
41
+ summarize: (json: any, args: z.infer<Input>) => string
42
+ /** Images or files worth attaching as `resource_link` blocks (at most a handful). */
43
+ links?: (json: any) => ResourceLinkOut[]
44
+ /** The route it calls, `GET /api/v1/recipes/{id}`, when `routeOf` can't read it from `call` (a path built in steps). */
45
+ route?: string
46
+ }
47
+
48
+ /** Where a tool's route is published in its MCP listing, so a gateway can tell which tool a guide's route means. */
49
+ export const ROUTE_META = 'api-kit/route'
50
+
51
+ /**
52
+ * A tool's route as `METHOD /path/{param}`: its own `route`, or `call` run with each argument as its `{name}`. Undefined
53
+ * when neither works (a call that reads its arguments' values).
54
+ *
55
+ * @example
56
+ * routeOf(mcpTool({ name: 'get_recipe', input: z.object({ id: z.string() }), call: ({ id }) => ({ path: `/api/v1/recipes/${id}` }), … }))
57
+ * // → 'GET /api/v1/recipes/{id}'
58
+ */
59
+ export const routeOf = (tool: McpTool): string | undefined => {
60
+ if (tool.route) return tool.route
61
+ try {
62
+ const placeholders = Object.fromEntries(Object.keys(tool.input.shape).map((key) => [key, `{${key}}`]))
63
+ const call = tool.call(placeholders as never)
64
+ return `${call.method ?? 'GET'} ${decodeURIComponent(call.path)}`
65
+ } catch {
66
+ return undefined
67
+ }
68
+ }
69
+
70
+ /** Keeps the input type when building a list of tools. */
71
+ export const mcpTool = <Input extends z.ZodObject>(tool: McpTool<Input>) => tool as unknown as McpTool
72
+
73
+ /**
74
+ * A resource a client can attach (a recipe with its picture, a menu's courses): a URI template and the API calls whose
75
+ * answers make it up — JSON as text, anything else (images) as a blob. Read through the routes with the caller's
76
+ * credentials, like tools.
77
+ */
78
+ export type McpResource = {
79
+ name: string
80
+ title: string
81
+ description: string
82
+ /** RFC 6570, e.g. `cookbook://recipes/{id}`. */
83
+ uriTemplate: string
84
+ /** The calls whose answers make up the resource, in order; an `optional` one (an image) is left out when it fails. */
85
+ read: (variables: Record<string, string>) => (ApiCall & { optional?: boolean })[]
86
+ }
87
+
88
+ /** Keeps a resource's shape checked when building the list. */
89
+ export const mcpResource = (resource: McpResource) => resource
90
+
91
+ export type McpPrompt = { name: string; title: string; description: string; arguments?: z.ZodObject; text: (args: Record<string, string>) => string }
92
+
93
+ export type McpServerInfo = { name: string; version: string; title?: string; description?: string; websiteUrl?: string; icons?: { src: string; mimeType?: string; sizes?: string[] }[] }
94
+
95
+ const HOUR = 3_600_000
96
+
97
+ /** A problem+json body (or anything else) as one line an agent can act on. */
98
+ export const problemText = (json: any, status: number) =>
99
+ json && typeof json === 'object' && 'title' in json ? `${json.status ?? status} ${json.code ?? ''} ${json.title}: ${json.detail ?? ''}`.replace(/\s+/g, ' ').trim() : `HTTP ${status}`
100
+
101
+ /**
102
+ * A slow route answers 202 with a run in `Location`: the tool polls it (up to `waitMs`), then asks again for the
103
+ * cached result, so a client sees one answer. Past the wait it says which run to check.
104
+ */
105
+ export const callWaiting = async (callApi: CallApi, call: ApiCall, auth: AuthInfo | undefined, waitMs: number) => {
106
+ let response = await callApi(call, auth)
107
+ const location = response.headers.get('location')
108
+ if (response.status !== 202 || !location) return { response, pending: null }
109
+ const until = Date.now() + waitMs
110
+ while (Date.now() < until) {
111
+ await new Promise((resolve) => setTimeout(resolve, 3000))
112
+ const run = (await (await callApi({ path: location }, auth)).json().catch(() => null)) as { status?: string } | null
113
+ if (run?.status === 'done') return { response: await callApi(call, auth), pending: null }
114
+ if (run?.status === 'failed') return { response: await callApi({ path: location }, auth), pending: null }
115
+ }
116
+ response = await callApi({ path: location }, auth)
117
+ return { response, pending: location.split('/').pop()! }
118
+ }
119
+
120
+ /** A factory that also carries its server info and tools, so `mountMcp` can publish the card and the guide can list the tools. */
121
+ export type AgentMcpFactory = McpServerFactory & { info: McpServerInfo; tools: McpTool[]; resources: McpResource[]; instructions?: string; prompts?: McpPrompt[] }
122
+
123
+ /** `toolsTtlMs`: how long clients may cache `tools/list` (default an hour); shorter when the tools change without a deploy. */
124
+ export const mcpServerFactory = ({ info, instructions, tools, resources = [], prompts = [], skills, callApi, waitMs = 60_000, toolsTtlMs = HOUR }: { info: McpServerInfo; instructions: string; tools: McpTool[]; resources?: McpResource[]; prompts?: McpPrompt[]; skills?: SkillCatalog; callApi: CallApi; waitMs?: number; toolsTtlMs?: number }): AgentMcpFactory => Object.assign(() => {
125
+ // Lists only change with a deploy: let clients cache them for an hour
126
+ const cached = { ttlMs: HOUR, cacheScope: 'private' as const }
127
+ const server = new McpServer(info, { instructions, cacheHints: { 'server/discover': cached, 'tools/list': { ...cached, ttlMs: toolsTtlMs }, 'prompts/list': cached, 'resources/list': cached, 'resources/templates/list': cached } })
128
+ for (const tool of tools) {
129
+ const route = routeOf(tool)
130
+ server.registerTool(
131
+ tool.name,
132
+ { title: tool.title, description: tool.description, inputSchema: tool.input, ...(tool.output ? { outputSchema: tool.output } : {}), annotations: { title: tool.title, readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false, ...tool.annotations }, ...(route ? { _meta: { [ROUTE_META]: route } } : {}) },
133
+ async (args: Record<string, unknown>, ctx): Promise<CallToolResult> => {
134
+ const { response, pending } = await callWaiting(callApi, tool.call(args as never), ctx.http?.authInfo, waitMs)
135
+ const json = await response.json().catch(() => null)
136
+ if (pending) return { content: [{ type: 'text', text: `Still working: run ${pending}. Check it with get_run, then call ${tool.name} again with the same arguments (it answers at once when done).` }] }
137
+ if (!response.ok) return { isError: true, content: [{ type: 'text', text: problemText(json, response.status) }] }
138
+ return {
139
+ content: [{ type: 'text', text: tool.summarize(json, args as never) }, ...(tool.links?.(json) ?? []).map((link) => ({ type: 'resource_link' as const, ...link }))],
140
+ ...(json && typeof json === 'object' ? { structuredContent: json } : {}),
141
+ }
142
+ },
143
+ )
144
+ }
145
+ for (const prompt of prompts) {
146
+ server.registerPrompt(prompt.name, { title: prompt.title, description: prompt.description, ...(prompt.arguments ? { argsSchema: prompt.arguments } : {}) } as never, ((args: Record<string, string> = {}) => ({
147
+ description: prompt.description,
148
+ messages: [{ role: 'user' as const, content: { type: 'text' as const, text: prompt.text(args) } }],
149
+ })) as never)
150
+ }
151
+ for (const resource of resources) {
152
+ server.registerResource(
153
+ resource.name,
154
+ new ResourceTemplate(resource.uriTemplate, { list: undefined }),
155
+ { title: resource.title, description: resource.description, cacheHint: cached },
156
+ async (uri, variables, ctx) => {
157
+ const auth = ctx.http?.authInfo
158
+ const values = Object.fromEntries(Object.entries(variables).map(([key, value]) => [key, Array.isArray(value) ? value[0]! : String(value)]))
159
+ const parts = await Promise.all(
160
+ resource.read(values).map(async ({ optional, ...call }) => {
161
+ const response = await callApi(call, auth)
162
+ if (!response.ok && optional) return null
163
+ if (!response.ok) throw new Error(problemText(await response.json().catch(() => null), response.status))
164
+ const type = response.headers.get('content-type') ?? 'application/octet-stream'
165
+ return /json|text|markdown/.test(type)
166
+ ? { uri: uri.href, mimeType: type.split(';')[0]!, text: await response.text() }
167
+ : { uri: uri.href, mimeType: type.split(';')[0]!, blob: Buffer.from(await response.arrayBuffer()).toString('base64') }
168
+ }),
169
+ )
170
+ return { contents: parts.filter((part) => part !== null) }
171
+ },
172
+ )
173
+ }
174
+ skills?.attach(server)
175
+ return server
176
+ }, { info, tools, resources, instructions, prompts })
177
+
178
+ export const FORWARDED = ['authorization', 'cf-access-jwt-assertion'] as const
179
+
180
+ /** Tools run the app's routes in-process with the caller's own credentials: no network hop, same auth decision. */
181
+ export const inProcess = (app: { request: (input: string, init?: RequestInit) => Response | Promise<Response> }): CallApi => async (call, auth) => {
182
+ const forwarded = (auth?.extra?.headers ?? {}) as Record<string, string>
183
+ return app.request(pathWith(call.path, call.query), {
184
+ method: call.method ?? 'GET',
185
+ headers: { accept: 'application/json', ...(call.body === undefined ? {} : { 'content-type': 'application/json' }), ...forwarded },
186
+ ...(call.body === undefined ? {} : { body: JSON.stringify(call.body) }),
187
+ })
188
+ }
189
+
190
+ /** For a local stdio server: the same tools against the remote API with the client's own key. */
191
+ export const overHttp = (origin: string, key: string, fetchImpl: typeof fetch = fetch): CallApi => (call) =>
192
+ fetchImpl(origin + pathWith(call.path, call.query), {
193
+ method: call.method ?? 'GET',
194
+ headers: { accept: 'application/json', authorization: `Bearer ${key}`, ...(call.body === undefined ? {} : { 'content-type': 'application/json' }) },
195
+ ...(call.body === undefined ? {} : { body: JSON.stringify(call.body) }),
196
+ })
197
+
198
+ /**
199
+ * 2026-07-28 has no `initialize` (`server/discover` replaced it), so the SDK answers -32601, which reads to an agent as
200
+ * a broken server. Agents still open with it out of habit: answer with what `server/discover` would, and say so.
201
+ */
202
+ const modernInitialize = async (c: Context, factory: AgentMcpFactory) => {
203
+ if (c.req.method !== 'POST' || c.req.header('mcp-protocol-version') !== MODERN_PROTOCOL_VERSION) return null
204
+ const message = (await c.req.raw.clone().json().catch(() => null)) as { jsonrpc?: string; id?: string | number; method?: string } | null
205
+ if (message?.method !== 'initialize' || message.id === undefined) return null
206
+ const result = {
207
+ protocolVersion: MODERN_PROTOCOL_VERSION,
208
+ // As the server itself declares them: an app may announce changes on a listen stream (`handler.notify`)
209
+ capabilities: { tools: { listChanged: true }, ...(factory.prompts?.length ? { prompts: { listChanged: true } } : {}), ...(factory.resources.length ? { resources: { listChanged: true } } : {}) },
210
+ serverInfo: factory.info,
211
+ instructions: `${factory.instructions ?? ''}\n\nThis server speaks MCP ${MODERN_PROTOCOL_VERSION}, which is stateless: no initialize or session is needed. Call tools/list or any method directly with the MCP-Protocol-Version and Mcp-Method headers (server/discover returns this same information).`.trim(),
212
+ }
213
+ return c.json({ jsonrpc: '2.0', id: message.id, result })
214
+ }
215
+
216
+ /**
217
+ * Serves MCP at `path` (POST; GET and DELETE answer 405 as the spec asks). Authentication has already run (the app's
218
+ * `authenticate` guards this path like any other); the caller's credentials ride along in `authInfo` so tools reuse
219
+ * them. Browsers' Origin is checked against `allowedHostnames`.
220
+ */
221
+ export const mountMcp = (app: Hono<any, any, any>, { path = '/mcp', factory, allowedHostnames }: { path?: string; factory: AgentMcpFactory; allowedHostnames: string[] }) => {
222
+ const handler = createMcpHandler(factory, { responseMode: 'auto' })
223
+ // The Server Card (SEP-2127): at its reserved place beside the endpoint, and where domain scanners look
224
+ const card = (origin: string) => serverCard(origin, path, factory.info, [MODERN_PROTOCOL_VERSION, ...SUPPORTED_PROTOCOL_VERSIONS])
225
+ recordMounted(app, { mcp: { path, card, tools: factory.tools, resources: factory.resources } })
226
+ for (const at of [`${path}/server-card`, WELL_KNOWN.serverCard]) app.get(at, (c: Context) => sendPublic(c, JSON.stringify(card(originOf(c)), null, 2), SERVER_CARD_TYPE))
227
+ app.all(path, async (c: Context) => {
228
+ const rejected = originValidationResponse(c.req.raw, allowedHostnames)
229
+ if (rejected) return rejected
230
+ const greeting = await modernInitialize(c, factory)
231
+ if (greeting) return greeting
232
+ const caller = c.get('caller' as never) as { kind: string; name?: string; email?: string } | undefined
233
+ const authInfo: AuthInfo = {
234
+ token: (c.req.header('authorization') ?? '').replace(/^bearer\s+/i, ''),
235
+ clientId: caller?.name ?? caller?.email ?? 'anonymous',
236
+ scopes: [],
237
+ // A static key never expires; the SDK treats a missing expiry as expired
238
+ expiresAt: Math.floor(Date.now() / 1000) + 3600,
239
+ // Tools call the routes in-process: pass the caller's credentials, and the public origin so links stay absolute
240
+ extra: {
241
+ headers: {
242
+ ...Object.fromEntries(FORWARDED.flatMap((name) => (c.req.header(name) ? [[name, c.req.header(name)!]] : []))),
243
+ 'x-forwarded-proto': new URL(originOf(c)).protocol.replace(':', ''),
244
+ 'x-forwarded-host': new URL(originOf(c)).host,
245
+ },
246
+ },
247
+ }
248
+ return handler.fetch(c.req.raw, { authInfo })
249
+ })
250
+ return handler
251
+ }
@@ -0,0 +1,29 @@
1
+ # oauth
2
+
3
+ The app as its own OAuth server, so MCP clients (Claude, ChatGPT) and agents connect without a pasted key: dynamic
4
+ client registration, authorization code with PKCE, refresh rotation, revocation, agent registration through
5
+ `/auth.md` with a 6-digit owner claim, ES256 tokens with `read` / `write` scopes, and the metadata documents.
6
+
7
+ Needs: `core`, `db`.
8
+
9
+ ```ts
10
+ import { accessOwner, createAgentApi, createOAuth, dbOAuthStore, generateOAuthKey } from './kit/index.js'
11
+
12
+ const oauth = env.OAUTH_SIGNING_KEY
13
+ ? createOAuth({ name: 'Notes', signingKey: env.OAUTH_SIGNING_KEY, store: await dbOAuthStore(db), owner: access ? accessOwner(access) : async () => null })
14
+ : undefined
15
+ const app = createAgentApi({ …, oauth }) // mounts /oauth/*, the metadata, and accepts its tokens
16
+ ```
17
+
18
+ | File | What |
19
+ |---|---|
20
+ | `index.ts` | `createOAuth` (the server; `revokeClient` is the owner's kill switch), `generateOAuthKey` (a new signing key) |
21
+ | `store.ts` | `dbOAuthStore(db)` (in the app's database; drops expired rows at start), `memoryOAuthStore` |
22
+ | `schema.ts` | the `oauth` table and `OAUTH_MIGRATIONS` |
23
+
24
+ ## Rules
25
+
26
+ - The signing key lives in the app's secrets, never in the repo; rotating it signs everyone out.
27
+ - `owner` decides who may approve: behind Cloudflare Access, `accessOwner(access)` (the signed-in email).
28
+ - Keep the OAuth paths open (`OAUTH_OPEN_PATHS`); `createAgentApi` does it when `oauth` is set, private mode included.
29
+ - Standard: `docs/STANDARD.md` §7.