@better-forge/mcp 0.1.0 → 0.1.1

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/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
- MIT License
2
-
3
- Copyright (c) 2026 BetterStatusPage 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.
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BetterStatusPage 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 CHANGED
@@ -1,14 +1,14 @@
1
- # @better-forge/mcp
2
-
3
- A Model Context Protocol endpoint for a Fastify app (Streamable HTTP, stateless).
4
-
5
- - **Bearer only.** Cookies are never read, so a web page cannot use the endpoint; a request with a foreign `Origin`, or
6
- a `Host` that is not the public URL, is refused.
7
- - **Scoped tools.** Each tool names the permission it needs and whether it only reads, writes, or is destructive (announced to the client as annotations); a tool can only GET, POST, PATCH or PUT, never DELETE; `tools/list` shows a caller only the tools its token may use.
8
- - **The host's own API does the work.** A tool reads through `ctx.get(path)`, a request to the same app with the caller's
9
- `Authorization` header, so validation, permissions, rate limits and audit stay where they are.
10
- - **Bounded, plain-data output.** Strings are stripped of control, bidi and zero-width characters and cut to 2000
11
- characters; a result is cut to 64 KB (a list under `items` is shortened first).
12
-
13
- The package knows nothing about what the tools are about; the product supplies them with `defineTool` and the
14
- authentication with `authenticate`. Depends on: `@better-forge/contracts`.
1
+ # @better-forge/mcp
2
+
3
+ A Model Context Protocol endpoint for a Fastify app (Streamable HTTP, stateless).
4
+
5
+ - **Bearer only.** Cookies are never read, so a web page cannot use the endpoint; a request with a foreign `Origin`, or
6
+ a `Host` that is not the public URL, is refused.
7
+ - **Scoped tools.** Each tool names the permission it needs and whether it only reads, writes, or is destructive (announced to the client as annotations); a tool can only GET, POST, PATCH or PUT, never DELETE; `tools/list` shows a caller only the tools its token may use.
8
+ - **The host's own API does the work.** A tool reads through `ctx.get(path)`, a request to the same app with the caller's
9
+ `Authorization` header, so validation, permissions, rate limits and audit stay where they are.
10
+ - **Bounded, plain-data output.** Strings are stripped of control, bidi and zero-width characters and cut to 2000
11
+ characters; a result is cut to 64 KB (a list under `items` is shortened first).
12
+
13
+ The package knows nothing about what the tools are about; the product supplies them with `defineTool` and the
14
+ authentication with `authenticate`. Depends on: `@better-forge/contracts`.
package/package.json CHANGED
@@ -1,54 +1,54 @@
1
- {
2
- "name": "@better-forge/mcp",
3
- "version": "0.1.0",
4
- "description": "Model Context Protocol endpoint for a Fastify app: Bearer-only access, scoped tools, bounded and sanitised output.",
5
- "license": "MIT",
6
- "repository": {
7
- "type": "git",
8
- "url": "git+https://github.com/BElluu/Better-Forge.git",
9
- "directory": "packages/mcp"
10
- },
11
- "homepage": "https://github.com/BElluu/Better-Forge/tree/main/packages/mcp#readme",
12
- "bugs": {
13
- "url": "https://github.com/BElluu/Better-Forge/issues"
14
- },
15
- "publishConfig": {
16
- "access": "public"
17
- },
18
- "type": "module",
19
- "main": "./dist/index.js",
20
- "types": "./dist/index.d.ts",
21
- "exports": {
22
- ".": {
23
- "types": "./dist/index.d.ts",
24
- "import": "./dist/index.js"
25
- },
26
- "./package.json": "./package.json"
27
- },
28
- "files": [
29
- "dist",
30
- "src",
31
- "!src/**/*.test.*"
32
- ],
33
- "scripts": {
34
- "build": "tsc -p tsconfig.json",
35
- "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
36
- },
37
- "dependencies": {
38
- "@modelcontextprotocol/sdk": "1.32.1",
39
- "@better-forge/contracts": "0.1.0"
40
- },
41
- "peerDependencies": {
42
- "fastify": "^5.3.2",
43
- "zod": "^4.4.3"
44
- },
45
- "devDependencies": {
46
- "@types/node": "^26.6.2",
47
- "fastify": "^5.3.2",
48
- "typescript": "^5.7.3",
49
- "zod": "^4.4.3"
50
- },
51
- "engines": {
52
- "node": ">=26.10.0"
53
- }
54
- }
1
+ {
2
+ "name": "@better-forge/mcp",
3
+ "version": "0.1.1",
4
+ "description": "Model Context Protocol endpoint for a Fastify app: Bearer-only access, scoped tools, bounded and sanitised output.",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/BElluu/Better-Forge.git",
9
+ "directory": "packages/mcp"
10
+ },
11
+ "homepage": "https://github.com/BElluu/Better-Forge/tree/main/packages/mcp#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/BElluu/Better-Forge/issues"
14
+ },
15
+ "publishConfig": {
16
+ "access": "public"
17
+ },
18
+ "type": "module",
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./dist/index.d.ts",
24
+ "import": "./dist/index.js"
25
+ },
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "src",
31
+ "!src/**/*.test.*"
32
+ ],
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.json",
35
+ "dev": "tsc -p tsconfig.json --watch --preserveWatchOutput"
36
+ },
37
+ "dependencies": {
38
+ "@modelcontextprotocol/sdk": "1.32.1",
39
+ "@better-forge/contracts": "0.1.1"
40
+ },
41
+ "peerDependencies": {
42
+ "fastify": "^5.3.2",
43
+ "zod": "^4.4.3"
44
+ },
45
+ "devDependencies": {
46
+ "@types/node": "^26.6.2",
47
+ "fastify": "^5.3.2",
48
+ "typescript": "^5.7.3",
49
+ "zod": "^4.4.3"
50
+ },
51
+ "engines": {
52
+ "node": ">=26.10.0"
53
+ }
54
+ }
package/src/endpoint.ts CHANGED
@@ -1,163 +1,163 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
- import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
3
- import { tokenAllows } from '@better-forge/contracts'
4
- import { z } from 'zod'
5
- import type { FastifyInstance, FastifyRequest, onRequestAsyncHookHandler } from 'fastify'
6
- import { crossSiteProblem } from './guard.js'
7
- import { boundedJson, cleanDeep, McpToolError, cleanText } from './output.js'
8
- import { SAFE_PATH, type McpTool, type McpToolContext } from './tools.js'
9
-
10
- /** Who is calling: the permissions of the token and the header to hand on to the host's own API. */
11
- export interface McpCaller {
12
- authorization: string
13
- scopes: readonly string[]
14
- }
15
-
16
- export interface McpEndpointOptions {
17
- /** Path of the endpoint. */
18
- path: string
19
- name: string
20
- version: string
21
- /** Shown to the agent once, when it connects. Static text; nothing that came from the data belongs here. */
22
- instructions: string
23
- tools: readonly McpTool[]
24
- /** The caller behind a `Bearer` token, or null. Cookies are never looked at: a browser must not be able to use this endpoint. */
25
- authenticate(req: FastifyRequest): Promise<McpCaller | null>
26
- /** Whether the endpoint answers at all. Asked first on every request: when false it is indistinguishable from a path that does not exist. */
27
- enabled(): boolean | Promise<boolean>
28
- /** Whether tools that change data are offered. When false a caller sees only the read-only ones, whatever its token may do. */
29
- allowChanges(): boolean | Promise<boolean>
30
- /** The address visitors use; known only when configured. */
31
- publicUrl(): string | undefined
32
- /** Options of @fastify/rate-limit for the route. */
33
- rateLimit?: Record<string, unknown>
34
- }
35
-
36
- const MAX_BODY_BYTES = 256 * 1024
37
-
38
- /** The caller's view of the host's API: each call is a request to the same app, so its checks, limits and audit apply unchanged. */
39
- function contextFor(app: FastifyInstance, caller: McpCaller): McpToolContext {
40
- return {
41
- can: (scope) => tokenAllows(caller.scopes, scope),
42
- get: (path) => request('GET', path),
43
- send: (method, path, body, contentType) => {
44
- // The types already say so; this holds when a tool is written loosely.
45
- if (!['POST', 'PATCH', 'PUT'].includes(method)) throw new McpToolError('A tool tried to send a request that is not allowed')
46
- return request(method, path, body, contentType)
47
- },
48
- }
49
-
50
- async function request(method: 'GET' | 'POST' | 'PATCH' | 'PUT', url: string, body?: unknown, bodyType?: string): Promise<unknown> {
51
- const [path = '', ...query] = url.split('?')
52
- // A request that changes data has no query: a tool must not be able to reach, say, an apply with a flag that deletes.
53
- const allowed = ['GET', 'POST', 'PATCH', 'PUT'].includes(method) && SAFE_PATH.test(path) && !path.includes('..') && (method === 'GET' || query.length === 0)
54
- if (!allowed) throw new McpToolError('A tool tried to send a request that is not allowed')
55
- const headers: Record<string, string> = { authorization: caller.authorization }
56
- if (bodyType) headers['content-type'] = bodyType
57
- const response = await app.inject({
58
- method, url, headers,
59
- ...(method === 'GET' ? {} : { payload: bodyType ? String(body) : (body as object | undefined) ?? {} }),
60
- })
61
- const contentType = String(response.headers['content-type'] ?? '')
62
- if (response.statusCode >= 400) {
63
- const failure = contentType.includes('json') ? (response.json() as { error?: unknown; problems?: unknown }) : {}
64
- const problems = Array.isArray(failure.problems) ? ` ${JSON.stringify(failure.problems).slice(0, 1500)}` : ''
65
- throw new McpToolError(`${response.statusCode}: ${typeof failure.error === 'string' ? cleanText(failure.error) : 'The request failed'}${cleanText(problems)}`)
66
- }
67
- if (response.statusCode === 204) return { ok: true }
68
- return contentType.includes('json') ? response.json() : response.body
69
- }
70
- }
71
-
72
- function annotationsFor(access: McpTool['access']) {
73
- return access === 'read'
74
- ? { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
75
- : { readOnlyHint: false, destructiveHint: access === 'destructive', idempotentHint: false, openWorldHint: false }
76
- }
77
-
78
- function serverFor(app: FastifyInstance, options: McpEndpointOptions, caller: McpCaller, allowChanges: boolean): McpServer {
79
- const server = new McpServer({ name: options.name, version: options.version }, { instructions: options.instructions })
80
- const context = contextFor(app, caller)
81
- for (const tool of options.tools) {
82
- if (!tokenAllows(caller.scopes, tool.scope)) continue
83
- if (tool.access !== 'read' && !allowChanges) continue
84
- server.registerTool(
85
- tool.name,
86
- {
87
- title: tool.title,
88
- description: tool.description,
89
- // Strict: an argument that does not exist is an error the agent sees, not a change that silently did not happen.
90
- inputSchema: z.strictObject(tool.input),
91
- annotations: annotationsFor(tool.access),
92
- },
93
- async (args: unknown) => {
94
- try {
95
- const data = await (tool.handler as (args: unknown, ctx: McpToolContext) => Promise<unknown>)(args, context)
96
- return { content: [{ type: 'text' as const, text: boundedJson(cleanDeep(data)) }] }
97
- } catch (error) {
98
- // A refused or failed request is the tool's answer; anything unexpected is reported without its details.
99
- const text = error instanceof McpToolError ? error.message : 'The tool failed'
100
- return { isError: true, content: [{ type: 'text' as const, text }] }
101
- }
102
- },
103
- )
104
- }
105
- return server
106
- }
107
-
108
- /**
109
- * Registers `POST <path>`: the MCP Streamable HTTP transport in its stateless form (a request carries everything, no
110
- * session is kept). The route accepts a `Bearer` token only and lists just the tools that token may use.
111
- */
112
- export function registerMcpEndpoint(app: FastifyInstance, options: McpEndpointOptions): void {
113
- // First of everything, before authentication and before the body is read: an endpoint that is off leaks nothing.
114
- const gate: onRequestAsyncHookHandler = async (_req, reply) => {
115
- let on = false
116
- try { on = await options.enabled() } catch { /* not ready: off */ }
117
- if (!on) return reply.callNotFound()
118
- }
119
-
120
- app.route({
121
- method: ['GET', 'DELETE'],
122
- url: options.path,
123
- onRequest: gate,
124
- handler: (_req, reply) => reply.code(405).header('allow', 'POST').send({ error: 'Use POST' }),
125
- })
126
-
127
- app.post(options.path, { onRequest: gate, bodyLimit: MAX_BODY_BYTES, config: { rateLimit: options.rateLimit } }, async (req, reply) => {
128
- const problem = crossSiteProblem(req.headers, options.publicUrl())
129
- if (problem) return reply.code(403).send({ error: problem })
130
-
131
- // One call per request: a batch would spend one rate-limit hit on up to a hundred calls.
132
- if (Array.isArray(req.body)) return reply.code(400).send({ error: 'Batched requests are not accepted' })
133
-
134
- let caller: McpCaller | null = null
135
- try { caller = await options.authenticate(req) } catch { /* not signed in */ }
136
- if (!caller) return reply.code(401).header('www-authenticate', 'Bearer realm="mcp"').send({ error: 'Unauthorized' })
137
-
138
- // Not knowing is not allowing.
139
- let allowChanges = false
140
- try { allowChanges = await options.allowChanges() } catch { /* read-only */ }
141
- const server = serverFor(app, options, caller, allowChanges)
142
- // Without a session id generator the transport is stateless.
143
- const transport = new StreamableHTTPServerTransport({ enableJsonResponse: true })
144
- // The transport writes the response itself, so Fastify's own hooks no longer run: set what they would.
145
- reply.hijack()
146
- const res = reply.raw
147
- res.setHeader('x-content-type-options', 'nosniff')
148
- res.setHeader('cache-control', 'no-store')
149
- res.on('close', () => { void transport.close(); void server.close() })
150
- try {
151
- // The SDK's own transport type does not satisfy exactOptionalPropertyTypes.
152
- await server.connect(transport as unknown as Parameters<McpServer['connect']>[0])
153
- await transport.handleRequest(req.raw, res, req.body)
154
- } catch {
155
- if (res.headersSent) res.destroy()
156
- else {
157
- res.statusCode = 500
158
- res.setHeader('content-type', 'application/json')
159
- res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error' }, id: null }))
160
- }
161
- }
162
- })
163
- }
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
3
+ import { tokenAllows } from '@better-forge/contracts'
4
+ import { z } from 'zod'
5
+ import type { FastifyInstance, FastifyRequest, onRequestAsyncHookHandler } from 'fastify'
6
+ import { crossSiteProblem } from './guard.js'
7
+ import { boundedJson, cleanDeep, McpToolError, cleanText } from './output.js'
8
+ import { SAFE_PATH, type McpTool, type McpToolContext } from './tools.js'
9
+
10
+ /** Who is calling: the permissions of the token and the header to hand on to the host's own API. */
11
+ export interface McpCaller {
12
+ authorization: string
13
+ scopes: readonly string[]
14
+ }
15
+
16
+ export interface McpEndpointOptions {
17
+ /** Path of the endpoint. */
18
+ path: string
19
+ name: string
20
+ version: string
21
+ /** Shown to the agent once, when it connects. Static text; nothing that came from the data belongs here. */
22
+ instructions: string
23
+ tools: readonly McpTool[]
24
+ /** The caller behind a `Bearer` token, or null. Cookies are never looked at: a browser must not be able to use this endpoint. */
25
+ authenticate(req: FastifyRequest): Promise<McpCaller | null>
26
+ /** Whether the endpoint answers at all. Asked first on every request: when false it is indistinguishable from a path that does not exist. */
27
+ enabled(): boolean | Promise<boolean>
28
+ /** Whether tools that change data are offered. When false a caller sees only the read-only ones, whatever its token may do. */
29
+ allowChanges(): boolean | Promise<boolean>
30
+ /** The address visitors use; known only when configured. */
31
+ publicUrl(): string | undefined
32
+ /** Options of @fastify/rate-limit for the route. */
33
+ rateLimit?: Record<string, unknown>
34
+ }
35
+
36
+ const MAX_BODY_BYTES = 256 * 1024
37
+
38
+ /** The caller's view of the host's API: each call is a request to the same app, so its checks, limits and audit apply unchanged. */
39
+ function contextFor(app: FastifyInstance, caller: McpCaller): McpToolContext {
40
+ return {
41
+ can: (scope) => tokenAllows(caller.scopes, scope),
42
+ get: (path) => request('GET', path),
43
+ send: (method, path, body, contentType) => {
44
+ // The types already say so; this holds when a tool is written loosely.
45
+ if (!['POST', 'PATCH', 'PUT'].includes(method)) throw new McpToolError('A tool tried to send a request that is not allowed')
46
+ return request(method, path, body, contentType)
47
+ },
48
+ }
49
+
50
+ async function request(method: 'GET' | 'POST' | 'PATCH' | 'PUT', url: string, body?: unknown, bodyType?: string): Promise<unknown> {
51
+ const [path = '', ...query] = url.split('?')
52
+ // A request that changes data has no query: a tool must not be able to reach, say, an apply with a flag that deletes.
53
+ const allowed = ['GET', 'POST', 'PATCH', 'PUT'].includes(method) && SAFE_PATH.test(path) && !path.includes('..') && (method === 'GET' || query.length === 0)
54
+ if (!allowed) throw new McpToolError('A tool tried to send a request that is not allowed')
55
+ const headers: Record<string, string> = { authorization: caller.authorization }
56
+ if (bodyType) headers['content-type'] = bodyType
57
+ const response = await app.inject({
58
+ method, url, headers,
59
+ ...(method === 'GET' ? {} : { payload: bodyType ? String(body) : (body as object | undefined) ?? {} }),
60
+ })
61
+ const contentType = String(response.headers['content-type'] ?? '')
62
+ if (response.statusCode >= 400) {
63
+ const failure = contentType.includes('json') ? (response.json() as { error?: unknown; problems?: unknown }) : {}
64
+ const problems = Array.isArray(failure.problems) ? ` ${JSON.stringify(failure.problems).slice(0, 1500)}` : ''
65
+ throw new McpToolError(`${response.statusCode}: ${typeof failure.error === 'string' ? cleanText(failure.error) : 'The request failed'}${cleanText(problems)}`)
66
+ }
67
+ if (response.statusCode === 204) return { ok: true }
68
+ return contentType.includes('json') ? response.json() : response.body
69
+ }
70
+ }
71
+
72
+ function annotationsFor(access: McpTool['access']) {
73
+ return access === 'read'
74
+ ? { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
75
+ : { readOnlyHint: false, destructiveHint: access === 'destructive', idempotentHint: false, openWorldHint: false }
76
+ }
77
+
78
+ function serverFor(app: FastifyInstance, options: McpEndpointOptions, caller: McpCaller, allowChanges: boolean): McpServer {
79
+ const server = new McpServer({ name: options.name, version: options.version }, { instructions: options.instructions })
80
+ const context = contextFor(app, caller)
81
+ for (const tool of options.tools) {
82
+ if (!tokenAllows(caller.scopes, tool.scope)) continue
83
+ if (tool.access !== 'read' && !allowChanges) continue
84
+ server.registerTool(
85
+ tool.name,
86
+ {
87
+ title: tool.title,
88
+ description: tool.description,
89
+ // Strict: an argument that does not exist is an error the agent sees, not a change that silently did not happen.
90
+ inputSchema: z.strictObject(tool.input),
91
+ annotations: annotationsFor(tool.access),
92
+ },
93
+ async (args: unknown) => {
94
+ try {
95
+ const data = await (tool.handler as (args: unknown, ctx: McpToolContext) => Promise<unknown>)(args, context)
96
+ return { content: [{ type: 'text' as const, text: boundedJson(cleanDeep(data)) }] }
97
+ } catch (error) {
98
+ // A refused or failed request is the tool's answer; anything unexpected is reported without its details.
99
+ const text = error instanceof McpToolError ? error.message : 'The tool failed'
100
+ return { isError: true, content: [{ type: 'text' as const, text }] }
101
+ }
102
+ },
103
+ )
104
+ }
105
+ return server
106
+ }
107
+
108
+ /**
109
+ * Registers `POST <path>`: the MCP Streamable HTTP transport in its stateless form (a request carries everything, no
110
+ * session is kept). The route accepts a `Bearer` token only and lists just the tools that token may use.
111
+ */
112
+ export function registerMcpEndpoint(app: FastifyInstance, options: McpEndpointOptions): void {
113
+ // First of everything, before authentication and before the body is read: an endpoint that is off leaks nothing.
114
+ const gate: onRequestAsyncHookHandler = async (_req, reply) => {
115
+ let on = false
116
+ try { on = await options.enabled() } catch { /* not ready: off */ }
117
+ if (!on) return reply.callNotFound()
118
+ }
119
+
120
+ app.route({
121
+ method: ['GET', 'DELETE'],
122
+ url: options.path,
123
+ onRequest: gate,
124
+ handler: (_req, reply) => reply.code(405).header('allow', 'POST').send({ error: 'Use POST' }),
125
+ })
126
+
127
+ app.post(options.path, { onRequest: gate, bodyLimit: MAX_BODY_BYTES, config: { rateLimit: options.rateLimit } }, async (req, reply) => {
128
+ const problem = crossSiteProblem(req.headers, options.publicUrl())
129
+ if (problem) return reply.code(403).send({ error: problem })
130
+
131
+ // One call per request: a batch would spend one rate-limit hit on up to a hundred calls.
132
+ if (Array.isArray(req.body)) return reply.code(400).send({ error: 'Batched requests are not accepted' })
133
+
134
+ let caller: McpCaller | null = null
135
+ try { caller = await options.authenticate(req) } catch { /* not signed in */ }
136
+ if (!caller) return reply.code(401).header('www-authenticate', 'Bearer realm="mcp"').send({ error: 'Unauthorized' })
137
+
138
+ // Not knowing is not allowing.
139
+ let allowChanges = false
140
+ try { allowChanges = await options.allowChanges() } catch { /* read-only */ }
141
+ const server = serverFor(app, options, caller, allowChanges)
142
+ // Without a session id generator the transport is stateless.
143
+ const transport = new StreamableHTTPServerTransport({ enableJsonResponse: true })
144
+ // The transport writes the response itself, so Fastify's own hooks no longer run: set what they would.
145
+ reply.hijack()
146
+ const res = reply.raw
147
+ res.setHeader('x-content-type-options', 'nosniff')
148
+ res.setHeader('cache-control', 'no-store')
149
+ res.on('close', () => { void transport.close(); void server.close() })
150
+ try {
151
+ // The SDK's own transport type does not satisfy exactOptionalPropertyTypes.
152
+ await server.connect(transport as unknown as Parameters<McpServer['connect']>[0])
153
+ await transport.handleRequest(req.raw, res, req.body)
154
+ } catch {
155
+ if (res.headersSent) res.destroy()
156
+ else {
157
+ res.statusCode = 500
158
+ res.setHeader('content-type', 'application/json')
159
+ res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error' }, id: null }))
160
+ }
161
+ }
162
+ })
163
+ }
package/src/guard.ts CHANGED
@@ -1,19 +1,19 @@
1
- import type { IncomingHttpHeaders } from 'node:http'
2
-
3
- function parse(publicUrl: string | undefined): URL | null {
4
- if (!publicUrl) return null
5
- try { return new URL(publicUrl) } catch { return null }
6
- }
7
-
8
- /**
9
- * Why a request must not reach the endpoint, or null. Clients of this endpoint (CLI tools, desktop apps) send no
10
- * `Origin`; a browser always does, so any foreign origin is refused whatever the method. With a public URL known the
11
- * `Host` header must be that host too, so a name that resolves to this server by DNS rebinding gets nothing.
12
- */
13
- export function crossSiteProblem(headers: IncomingHttpHeaders, publicUrl: string | undefined): string | null {
14
- const own = parse(publicUrl)
15
- const origin = headers.origin
16
- if (origin !== undefined && origin !== own?.origin) return 'Requests from a web page are not accepted'
17
- if (own && headers.host?.toLowerCase() !== own.host.toLowerCase()) return 'The Host header does not match the public URL'
18
- return null
19
- }
1
+ import type { IncomingHttpHeaders } from 'node:http'
2
+
3
+ function parse(publicUrl: string | undefined): URL | null {
4
+ if (!publicUrl) return null
5
+ try { return new URL(publicUrl) } catch { return null }
6
+ }
7
+
8
+ /**
9
+ * Why a request must not reach the endpoint, or null. Clients of this endpoint (CLI tools, desktop apps) send no
10
+ * `Origin`; a browser always does, so any foreign origin is refused whatever the method. With a public URL known the
11
+ * `Host` header must be that host too, so a name that resolves to this server by DNS rebinding gets nothing.
12
+ */
13
+ export function crossSiteProblem(headers: IncomingHttpHeaders, publicUrl: string | undefined): string | null {
14
+ const own = parse(publicUrl)
15
+ const origin = headers.origin
16
+ if (origin !== undefined && origin !== own?.origin) return 'Requests from a web page are not accepted'
17
+ if (own && headers.host?.toLowerCase() !== own.host.toLowerCase()) return 'The Host header does not match the public URL'
18
+ return null
19
+ }
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
- export { registerMcpEndpoint } from './endpoint.js'
2
- export type { McpCaller, McpEndpointOptions } from './endpoint.js'
3
- export { crossSiteProblem } from './guard.js'
4
- export { boundedJson, cleanDeep, cleanText, McpToolError } from './output.js'
5
- export { defineTool, query } from './tools.js'
6
- export type { McpTool, McpToolContext, McpToolDefinition } from './tools.js'
1
+ export { registerMcpEndpoint } from './endpoint.js'
2
+ export type { McpCaller, McpEndpointOptions } from './endpoint.js'
3
+ export { crossSiteProblem } from './guard.js'
4
+ export { boundedJson, cleanDeep, cleanText, McpToolError } from './output.js'
5
+ export { defineTool, query } from './tools.js'
6
+ export type { McpTool, McpToolContext, McpToolDefinition } from './tools.js'
package/src/output.ts CHANGED
@@ -1,54 +1,54 @@
1
- /** Text that came from outside the instance (a monitored system, a visitor, an import) must reach an agent as plain data. */
2
- const MAX_TEXT_LENGTH = 2000
3
- const MAX_DEPTH = 12
4
- const MAX_OUTPUT_BYTES = 64 * 1024
5
-
6
- /** An escape sequence that sets colours or moves the cursor. */
7
- const ANSI = new RegExp(String.raw`${String.fromCharCode(27)}\[[0-9;?]*[ -/]*[@-~]`, 'g')
8
-
9
- /**
10
- * Control characters (tab and line breaks stay), bidi controls, zero-width characters, the byte order mark, variation
11
- * selectors, fillers and the Unicode tag characters, which people cannot see but a model reads.
12
- */
13
- const INVISIBLE: ReadonlyArray<readonly [number, number]> = [
14
- [0x00, 0x08], [0x0b, 0x0c], [0x0e, 0x1f], [0x7f, 0x9f],
15
- [0x00ad, 0x00ad], [0x061c, 0x061c], [0x115f, 0x1160], [0x180e, 0x180e],
16
- [0x200b, 0x200f], [0x2028, 0x202e], [0x2060, 0x2064], [0x2066, 0x2069], [0x3164, 0x3164],
17
- [0xfe00, 0xfe0f], [0xfeff, 0xfeff], [0xe0000, 0xe007f], [0xe0100, 0xe01ef],
18
- ]
19
-
20
- const isInvisible = (code: number): boolean => INVISIBLE.some(([from, to]) => code >= from && code <= to)
21
-
22
- export function cleanText(value: string): string {
23
- const clean = Array.from(value.replace(ANSI, '')).filter((char) => !isInvisible(char.codePointAt(0)!)).join('')
24
- return clean.length > MAX_TEXT_LENGTH ? `${clean.slice(0, MAX_TEXT_LENGTH)}…` : clean
25
- }
26
-
27
- /** Every string in a result, cleaned. */
28
- export function cleanDeep(value: unknown, depth = 0): unknown {
29
- if (typeof value === 'string') return cleanText(value)
30
- if (value === null || typeof value !== 'object') return value
31
- // Nothing below the limit is passed on unchecked.
32
- if (depth >= MAX_DEPTH) return '[nested too deep]'
33
- if (Array.isArray(value)) return value.map((each) => cleanDeep(each, depth + 1))
34
- return Object.fromEntries(Object.entries(value).map(([key, each]) => [cleanText(key), cleanDeep(each, depth + 1)]))
35
- }
36
-
37
- /** A failure the agent should read as the tool's answer rather than as a crash. */
38
- export class McpToolError extends Error {}
39
-
40
- /** A tool result as text. A list under `items` is shortened until it fits; anything else that is too large is refused. */
41
- export function boundedJson(data: unknown): string {
42
- const text = JSON.stringify(data)
43
- if (Buffer.byteLength(text) <= MAX_OUTPUT_BYTES) return text
44
- const holder = data as { items?: unknown[] } | null
45
- if (holder && typeof holder === 'object' && Array.isArray(holder.items)) {
46
- let keep = holder.items.length
47
- while (keep > 0) {
48
- keep = Math.floor(keep / 2)
49
- const shortened = JSON.stringify({ ...holder, items: holder.items.slice(0, keep), truncated: true, returnedItems: keep })
50
- if (Buffer.byteLength(shortened) <= MAX_OUTPUT_BYTES) return shortened
51
- }
52
- }
53
- throw new McpToolError('The result is too large to return. Narrow the query (a shorter period, a smaller limit, a filter).')
54
- }
1
+ /** Text that came from outside the instance (a monitored system, a visitor, an import) must reach an agent as plain data. */
2
+ const MAX_TEXT_LENGTH = 2000
3
+ const MAX_DEPTH = 12
4
+ const MAX_OUTPUT_BYTES = 64 * 1024
5
+
6
+ /** An escape sequence that sets colours or moves the cursor. */
7
+ const ANSI = new RegExp(String.raw`${String.fromCharCode(27)}\[[0-9;?]*[ -/]*[@-~]`, 'g')
8
+
9
+ /**
10
+ * Control characters (tab and line breaks stay), bidi controls, zero-width characters, the byte order mark, variation
11
+ * selectors, fillers and the Unicode tag characters, which people cannot see but a model reads.
12
+ */
13
+ const INVISIBLE: ReadonlyArray<readonly [number, number]> = [
14
+ [0x00, 0x08], [0x0b, 0x0c], [0x0e, 0x1f], [0x7f, 0x9f],
15
+ [0x00ad, 0x00ad], [0x061c, 0x061c], [0x115f, 0x1160], [0x180e, 0x180e],
16
+ [0x200b, 0x200f], [0x2028, 0x202e], [0x2060, 0x2064], [0x2066, 0x2069], [0x3164, 0x3164],
17
+ [0xfe00, 0xfe0f], [0xfeff, 0xfeff], [0xe0000, 0xe007f], [0xe0100, 0xe01ef],
18
+ ]
19
+
20
+ const isInvisible = (code: number): boolean => INVISIBLE.some(([from, to]) => code >= from && code <= to)
21
+
22
+ export function cleanText(value: string): string {
23
+ const clean = Array.from(value.replace(ANSI, '')).filter((char) => !isInvisible(char.codePointAt(0)!)).join('')
24
+ return clean.length > MAX_TEXT_LENGTH ? `${clean.slice(0, MAX_TEXT_LENGTH)}…` : clean
25
+ }
26
+
27
+ /** Every string in a result, cleaned. */
28
+ export function cleanDeep(value: unknown, depth = 0): unknown {
29
+ if (typeof value === 'string') return cleanText(value)
30
+ if (value === null || typeof value !== 'object') return value
31
+ // Nothing below the limit is passed on unchecked.
32
+ if (depth >= MAX_DEPTH) return '[nested too deep]'
33
+ if (Array.isArray(value)) return value.map((each) => cleanDeep(each, depth + 1))
34
+ return Object.fromEntries(Object.entries(value).map(([key, each]) => [cleanText(key), cleanDeep(each, depth + 1)]))
35
+ }
36
+
37
+ /** A failure the agent should read as the tool's answer rather than as a crash. */
38
+ export class McpToolError extends Error {}
39
+
40
+ /** A tool result as text. A list under `items` is shortened until it fits; anything else that is too large is refused. */
41
+ export function boundedJson(data: unknown): string {
42
+ const text = JSON.stringify(data)
43
+ if (Buffer.byteLength(text) <= MAX_OUTPUT_BYTES) return text
44
+ const holder = data as { items?: unknown[] } | null
45
+ if (holder && typeof holder === 'object' && Array.isArray(holder.items)) {
46
+ let keep = holder.items.length
47
+ while (keep > 0) {
48
+ keep = Math.floor(keep / 2)
49
+ const shortened = JSON.stringify({ ...holder, items: holder.items.slice(0, keep), truncated: true, returnedItems: keep })
50
+ if (Buffer.byteLength(shortened) <= MAX_OUTPUT_BYTES) return shortened
51
+ }
52
+ }
53
+ throw new McpToolError('The result is too large to return. Narrow the query (a shorter period, a smaller limit, a filter).')
54
+ }
package/src/tools.ts CHANGED
@@ -1,54 +1,54 @@
1
- import type { z } from 'zod'
2
-
3
- /** What a tool may do with the host application: read its own API with the credentials of the caller. */
4
- export interface McpToolContext {
5
- /** True when the caller's token holds the permission. */
6
- can(scope: string): boolean
7
- /** GET a path of the host application's API (`/api/v1/admin/monitors`); JSON comes back parsed, anything else as text. */
8
- get(path: string): Promise<unknown>
9
- /** POST, PATCH or PUT a body (JSON, or text when `contentType` is given) to a path of the host's API. Nothing else can be sent: no DELETE. */
10
- send(method: 'POST' | 'PATCH' | 'PUT', path: string, body: unknown, contentType?: string): Promise<unknown>
11
- }
12
-
13
- /**
14
- * What a tool does to the data: `read` changes nothing; `write` changes something that can be changed back or that
15
- * nobody sees yet; `destructive` is visible to other people or cannot be undone (an agent client asks first).
16
- */
17
- export type McpAccess = 'read' | 'write' | 'destructive'
18
-
19
- /** Where a tool may send a request: a path of the host, nothing that could leave it or change its meaning. */
20
- export const SAFE_PATH = /^\/(?!\/)[A-Za-z0-9_\-./]*$/
21
-
22
- export interface McpTool {
23
- name: string
24
- title: string
25
- description: string
26
- /** The permission a token needs to see and call the tool. */
27
- scope: string
28
- access: McpAccess
29
- input: z.ZodRawShape
30
- handler(args: never, ctx: McpToolContext): Promise<unknown>
31
- }
32
-
33
- export interface McpToolDefinition<Shape extends z.ZodRawShape> {
34
- name: string
35
- title: string
36
- description: string
37
- scope: string
38
- /** Defaults to `read`. */
39
- access?: McpAccess
40
- input: Shape
41
- handler(args: z.infer<z.ZodObject<Shape>>, ctx: McpToolContext): Promise<unknown>
42
- }
43
-
44
- export function defineTool<Shape extends z.ZodRawShape>(definition: McpToolDefinition<Shape>): McpTool {
45
- return { access: 'read', ...definition } as unknown as McpTool
46
- }
47
-
48
- /** Query string from the values that were given. */
49
- export function query(values: Record<string, string | number | boolean | undefined>): string {
50
- const params = new URLSearchParams()
51
- for (const [key, value] of Object.entries(values)) if (value !== undefined) params.set(key, String(value))
52
- const text = params.toString()
53
- return text ? `?${text}` : ''
54
- }
1
+ import type { z } from 'zod'
2
+
3
+ /** What a tool may do with the host application: read its own API with the credentials of the caller. */
4
+ export interface McpToolContext {
5
+ /** True when the caller's token holds the permission. */
6
+ can(scope: string): boolean
7
+ /** GET a path of the host application's API (`/api/v1/admin/monitors`); JSON comes back parsed, anything else as text. */
8
+ get(path: string): Promise<unknown>
9
+ /** POST, PATCH or PUT a body (JSON, or text when `contentType` is given) to a path of the host's API. Nothing else can be sent: no DELETE. */
10
+ send(method: 'POST' | 'PATCH' | 'PUT', path: string, body: unknown, contentType?: string): Promise<unknown>
11
+ }
12
+
13
+ /**
14
+ * What a tool does to the data: `read` changes nothing; `write` changes something that can be changed back or that
15
+ * nobody sees yet; `destructive` is visible to other people or cannot be undone (an agent client asks first).
16
+ */
17
+ export type McpAccess = 'read' | 'write' | 'destructive'
18
+
19
+ /** Where a tool may send a request: a path of the host, nothing that could leave it or change its meaning. */
20
+ export const SAFE_PATH = /^\/(?!\/)[A-Za-z0-9_\-./]*$/
21
+
22
+ export interface McpTool {
23
+ name: string
24
+ title: string
25
+ description: string
26
+ /** The permission a token needs to see and call the tool. */
27
+ scope: string
28
+ access: McpAccess
29
+ input: z.ZodRawShape
30
+ handler(args: never, ctx: McpToolContext): Promise<unknown>
31
+ }
32
+
33
+ export interface McpToolDefinition<Shape extends z.ZodRawShape> {
34
+ name: string
35
+ title: string
36
+ description: string
37
+ scope: string
38
+ /** Defaults to `read`. */
39
+ access?: McpAccess
40
+ input: Shape
41
+ handler(args: z.infer<z.ZodObject<Shape>>, ctx: McpToolContext): Promise<unknown>
42
+ }
43
+
44
+ export function defineTool<Shape extends z.ZodRawShape>(definition: McpToolDefinition<Shape>): McpTool {
45
+ return { access: 'read', ...definition } as unknown as McpTool
46
+ }
47
+
48
+ /** Query string from the values that were given. */
49
+ export function query(values: Record<string, string | number | boolean | undefined>): string {
50
+ const params = new URLSearchParams()
51
+ for (const [key, value] of Object.entries(values)) if (value !== undefined) params.set(key, String(value))
52
+ const text = params.toString()
53
+ return text ? `?${text}` : ''
54
+ }