@jinshuju/mcp 0.0.0-stage → 0.3.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/LICENSE +17 -0
- package/README.md +87 -2
- package/dist/analytics.d.ts +84 -0
- package/dist/analytics.js +192 -0
- package/dist/api.d.ts +54 -0
- package/dist/api.js +167 -0
- package/dist/app.d.ts +35 -0
- package/dist/app.js +224 -0
- package/dist/auth.d.ts +36 -0
- package/dist/auth.js +94 -0
- package/dist/cli-credential.d.ts +21 -0
- package/dist/cli-credential.js +120 -0
- package/dist/composites.d.ts +12 -0
- package/dist/composites.js +82 -0
- package/dist/config.d.ts +34 -0
- package/dist/config.js +44 -0
- package/dist/credential.d.ts +26 -0
- package/dist/credential.js +32 -0
- package/dist/generated/meta.json +114 -0
- package/dist/generated/operations.json +4152 -0
- package/dist/generated/schemas.json +32593 -0
- package/dist/generated/tools.json +6818 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +12 -0
- package/dist/node.d.ts +2 -0
- package/dist/node.js +27 -0
- package/dist/result.d.ts +10 -0
- package/dist/result.js +81 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +59 -0
- package/dist/stdio.d.ts +2 -0
- package/dist/stdio.js +29 -0
- package/dist/tools.d.ts +21 -0
- package/dist/tools.js +84 -0
- package/dist/types.d.ts +75 -0
- package/dist/types.js +2 -0
- package/dist/validation.d.ts +16 -0
- package/dist/validation.js +56 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.js +5 -0
- package/dist/worker.d.ts +13 -0
- package/dist/worker.js +30 -0
- package/package.json +74 -4
package/dist/app.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP entry (Hono): the same app runs on Node (node.ts) and Cloudflare Workers (worker.ts).
|
|
3
|
+
*
|
|
4
|
+
* GET / landing page
|
|
5
|
+
* GET /healthz health check
|
|
6
|
+
* GET /.well-known/oauth-protected-resource[/mcp] RFC 9728, pointing at account.jinshuju.net
|
|
7
|
+
* GET /.well-known/oauth-authorization-server RFC 8414 mirror (for older clients that probe only the resource domain)
|
|
8
|
+
* POST /mcp MCP Streamable HTTP (2026-07-28 stateless; the 2025 handshake falls back to stateless)
|
|
9
|
+
*
|
|
10
|
+
* The service may sit under a path prefix (e.g. https://host/jinshuju-mcp/…, with the platform stripping the prefix when
|
|
11
|
+
* forwarding): JINSHUJU_MCP_PUBLIC_URL gives the public address (prefix included; a comma-separated list gives several,
|
|
12
|
+
* picked by the request's Host, so a custom domain and the platform's default address both work), and resource_metadata in
|
|
13
|
+
* the 401 challenge points at the well-known document under this service's own path rather than the domain root; RFC 9728
|
|
14
|
+
* lets clients fetch the URL the challenge gives. When it is not set, the address is inferred from the request (trusting
|
|
15
|
+
* the reverse proxy's X-Forwarded-*).
|
|
16
|
+
*
|
|
17
|
+
* With POSTHOG_API_KEY set, every request's McpServer gets PostHog MCP analytics (analytics.ts), reported in waitUntil after
|
|
18
|
+
* the response goes out. Unexpected exceptions go to Sentry (worker.ts initializes it with withSentry; without SENTRY_DSN the
|
|
19
|
+
* SDK sends nothing; the Node entry is not wired): those a tool handler throws, an unreachable API during token verification,
|
|
20
|
+
* and anything a route does not catch. A 4xx / 5xx from the API is a normal tool result and is not reported.
|
|
21
|
+
*/
|
|
22
|
+
import { Hono } from 'hono';
|
|
23
|
+
import { buildOAuthProtectedResourceMetadata, createMcpHandler, isLegacyRequest, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
|
|
24
|
+
import { captureException, setUser } from '@sentry/cloudflare';
|
|
25
|
+
import { analyticsFromConfig, CONTEXT_INSTRUCTIONS } from './analytics.js';
|
|
26
|
+
import { JinshujuApi, TransportError } from './api.js';
|
|
27
|
+
import { TokenVerifier, bearerToken, challenge, loadAuthServerMetadata, userIdOf } from './auth.js';
|
|
28
|
+
import { DEFAULTS } from './config.js';
|
|
29
|
+
import { StaticCredential } from './credential.js';
|
|
30
|
+
import { buildServer, INSTRUCTIONS, META } from './server.js';
|
|
31
|
+
import { TOOLS } from './tools.js';
|
|
32
|
+
import { SERVER_TITLE, VERSION } from './version.js';
|
|
33
|
+
export const MCP_PATH = '/mcp';
|
|
34
|
+
export function createApp(options = {}) {
|
|
35
|
+
const cfg = { ...DEFAULTS, ...options.config };
|
|
36
|
+
const fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
|
|
37
|
+
const api = new JinshujuApi({ baseUrl: cfg.apiBaseUrl, fetch: fetchImpl, timeoutMs: cfg.timeoutMs });
|
|
38
|
+
const verifier = new TokenVerifier({ api, cacheTtlMs: cfg.tokenCacheTtlMs });
|
|
39
|
+
const analytics = options.analytics === undefined ? analyticsFromConfig(cfg, fetchImpl) : (options.analytics ?? undefined);
|
|
40
|
+
const instructions = analytics?.collectIntent ? `${INSTRUCTIONS}\n${CONTEXT_INSTRUCTIONS}` : INSTRUCTIONS;
|
|
41
|
+
let metadata;
|
|
42
|
+
const authServerMetadata = () => (metadata ??= options.authServerMetadata
|
|
43
|
+
? Promise.resolve(options.authServerMetadata)
|
|
44
|
+
: loadAuthServerMetadata(cfg.authServer, fetchImpl));
|
|
45
|
+
// One McpServer per request, with the token closed over in every tool handler; analytics is installed per request too,
|
|
46
|
+
// so the user and session follow the request. The analytics handle is stored per request object: the factory installs
|
|
47
|
+
// it, and the route takes it out to flush after the response goes out.
|
|
48
|
+
const trackers = new WeakMap();
|
|
49
|
+
const factory = async (ctx) => {
|
|
50
|
+
const token = ctx.authInfo?.token;
|
|
51
|
+
if (!token)
|
|
52
|
+
throw new Error('unauthenticated request reached the MCP handler');
|
|
53
|
+
const server = buildServer({
|
|
54
|
+
credential: new StaticCredential(token),
|
|
55
|
+
config: cfg,
|
|
56
|
+
fetch: fetchImpl,
|
|
57
|
+
instructions,
|
|
58
|
+
onToolError: reportToolError
|
|
59
|
+
});
|
|
60
|
+
if (analytics && ctx.requestInfo)
|
|
61
|
+
trackers.set(ctx.requestInfo, await analytics.instrument(server, ctx.authInfo));
|
|
62
|
+
return server;
|
|
63
|
+
};
|
|
64
|
+
// Tools send no progress notifications, so the response is one JSON (the default auto mode): it is assembled after the handler finishes, and this request's analytics is complete before it returns.
|
|
65
|
+
const handler = createMcpHandler(factory, { legacy: 'reject' });
|
|
66
|
+
/**
|
|
67
|
+
* Stateless service for the 2025 handshake (initialize + Mcp-Session-Id). It is wired here instead of using the SDK's
|
|
68
|
+
* own fallback only to answer with JSON instead of SSE: SSE sends the response headers before the handler runs, so the
|
|
69
|
+
* Mcp-Session-Id the PostHog SDK mints on initialize (carrying the client name and version, and sent back by the client
|
|
70
|
+
* on every later request) never reaches the headers, and tool call analytics would land after the response and before
|
|
71
|
+
* the flush.
|
|
72
|
+
*/
|
|
73
|
+
async function serveLegacy(request, authInfo) {
|
|
74
|
+
if (request.method.toUpperCase() !== 'POST')
|
|
75
|
+
return jsonRpcError(405, -32000, 'Method not allowed.');
|
|
76
|
+
const server = await factory({ era: 'legacy', authInfo, requestInfo: request });
|
|
77
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
78
|
+
sessionIdGenerator: undefined,
|
|
79
|
+
enableJsonResponse: true
|
|
80
|
+
});
|
|
81
|
+
try {
|
|
82
|
+
await server.connect(transport);
|
|
83
|
+
return await transport.handleRequest(request, { authInfo });
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
console.error('legacy MCP request failed:', error);
|
|
87
|
+
captureException(error);
|
|
88
|
+
return jsonRpcError(500, -32603, 'Internal server error.');
|
|
89
|
+
}
|
|
90
|
+
finally {
|
|
91
|
+
transport.close().catch(() => { });
|
|
92
|
+
server.close().catch(() => { });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Public base URL (no trailing slash, may include a path prefix). With addresses configured, picks one by the request's
|
|
97
|
+
* Host, falling back to the first; without, infers it from the request (trusting the reverse proxy's X-Forwarded-*).
|
|
98
|
+
*/
|
|
99
|
+
const publicUrls = cfg.publicUrls.map((u) => ({ base: u.replace(/\/$/, ''), host: new URL(u).host.toLowerCase() }));
|
|
100
|
+
function publicBase(request) {
|
|
101
|
+
const url = new URL(request.url);
|
|
102
|
+
const host = (request.headers.get('x-forwarded-host') ?? request.headers.get('host') ?? url.host)
|
|
103
|
+
.split(',')[0]
|
|
104
|
+
.trim();
|
|
105
|
+
if (publicUrls.length) {
|
|
106
|
+
const wanted = host.toLowerCase();
|
|
107
|
+
return (publicUrls.find((u) => u.host === wanted) ?? publicUrls[0]).base;
|
|
108
|
+
}
|
|
109
|
+
const proto = request.headers.get('x-forwarded-proto') ?? url.protocol.replace(':', '');
|
|
110
|
+
const prefix = (request.headers.get('x-forwarded-prefix') ?? '').replace(/\/$/, '');
|
|
111
|
+
return `${proto}://${host}${prefix}`;
|
|
112
|
+
}
|
|
113
|
+
const resourceUrl = (request) => new URL(`${publicBase(request)}${MCP_PATH}`);
|
|
114
|
+
const resourceMetadataUrl = (request) => `${publicBase(request)}/.well-known/oauth-protected-resource${MCP_PATH}`;
|
|
115
|
+
const json = (body, status = 200) => new Response(JSON.stringify(body), {
|
|
116
|
+
status,
|
|
117
|
+
headers: {
|
|
118
|
+
'Content-Type': 'application/json',
|
|
119
|
+
'Cache-Control': 'public, max-age=300',
|
|
120
|
+
'Access-Control-Allow-Origin': '*',
|
|
121
|
+
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
|
122
|
+
'Access-Control-Allow-Headers': 'mcp-protocol-version'
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
// Ignore trailing slashes: users often enter the address as /mcp/, which the default strict matching answers with 404.
|
|
126
|
+
const app = new Hono({ strict: false });
|
|
127
|
+
// Exceptions a route does not catch: Hono only logs them to console and answers 500 by default; also report them to Sentry here.
|
|
128
|
+
app.onError((error, c) => {
|
|
129
|
+
console.error('unhandled error:', error);
|
|
130
|
+
captureException(error);
|
|
131
|
+
return c.text('Internal Server Error', 500);
|
|
132
|
+
});
|
|
133
|
+
app.get('/', (c) => c.json({
|
|
134
|
+
name: SERVER_TITLE,
|
|
135
|
+
version: VERSION,
|
|
136
|
+
mcp: resourceUrl(c.req.raw).href,
|
|
137
|
+
docs: 'https://open.jinshuju.net/mcp',
|
|
138
|
+
tools: TOOLS.length,
|
|
139
|
+
generatedFrom: META.generatedFrom
|
|
140
|
+
}));
|
|
141
|
+
app.get('/healthz', (c) => c.text('ok'));
|
|
142
|
+
// RFC 9728 protected resource metadata: the paths with and without /mcp answer the same document (for clients that probe only the origin).
|
|
143
|
+
const prmPaths = ['/.well-known/oauth-protected-resource', `/.well-known/oauth-protected-resource${MCP_PATH}`];
|
|
144
|
+
app.on(['GET', 'OPTIONS'], prmPaths, async (c) => {
|
|
145
|
+
if (c.req.method === 'OPTIONS')
|
|
146
|
+
return json(null, 204);
|
|
147
|
+
const metadata = buildOAuthProtectedResourceMetadata({
|
|
148
|
+
oauthMetadata: await authServerMetadata(),
|
|
149
|
+
resourceServerUrl: resourceUrl(c.req.raw),
|
|
150
|
+
resourceName: SERVER_TITLE,
|
|
151
|
+
serviceDocumentationUrl: new URL('https://open.jinshuju.net/mcp'),
|
|
152
|
+
scopesSupported: ['public', 'forms', 'read_entries', 'write_entries', 'form_setting', 'users']
|
|
153
|
+
});
|
|
154
|
+
return json(metadata);
|
|
155
|
+
});
|
|
156
|
+
// RFC 8414 authorization server metadata mirror.
|
|
157
|
+
app.on(['GET', 'OPTIONS'], '/.well-known/oauth-authorization-server', async (c) => {
|
|
158
|
+
if (c.req.method === 'OPTIONS')
|
|
159
|
+
return json(null, 204);
|
|
160
|
+
return json(await authServerMetadata());
|
|
161
|
+
});
|
|
162
|
+
app.all(MCP_PATH, async (c) => {
|
|
163
|
+
const request = c.req.raw;
|
|
164
|
+
const prm = resourceMetadataUrl(request);
|
|
165
|
+
const token = bearerToken(request);
|
|
166
|
+
if (!token)
|
|
167
|
+
return challenge(prm, 'Missing or invalid access token');
|
|
168
|
+
let authInfo;
|
|
169
|
+
try {
|
|
170
|
+
authInfo = await verifier.verify(token);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
console.error('token verification failed:', error);
|
|
174
|
+
captureException(error);
|
|
175
|
+
return new Response(JSON.stringify({
|
|
176
|
+
error: 'server_error',
|
|
177
|
+
error_description: 'Cannot verify the token: the Jinshuju API is unreachable for now'
|
|
178
|
+
}), {
|
|
179
|
+
status: 503,
|
|
180
|
+
headers: { 'Content-Type': 'application/json', 'Retry-After': '5' }
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (!authInfo)
|
|
184
|
+
return challenge(prm, 'Missing or invalid access token');
|
|
185
|
+
const userId = userIdOf(authInfo);
|
|
186
|
+
if (userId)
|
|
187
|
+
setUser({ id: userId });
|
|
188
|
+
const response = (await isLegacyRequest(request))
|
|
189
|
+
? await serveLegacy(request, authInfo)
|
|
190
|
+
: await handler.fetch(request, { authInfo });
|
|
191
|
+
// The API answered 401 during a tool call (the token expired mid-way): make the next request verify again.
|
|
192
|
+
if (response.status === 401)
|
|
193
|
+
await verifier.forget(token);
|
|
194
|
+
const tracker = trackers.get(request);
|
|
195
|
+
if (tracker)
|
|
196
|
+
flushInBackground(c, tracker);
|
|
197
|
+
return response;
|
|
198
|
+
});
|
|
199
|
+
return app;
|
|
200
|
+
}
|
|
201
|
+
/** An exception a tool throws: an unreachable API / timeout is environmental and logged as a warning; anything else is our bug. */
|
|
202
|
+
function reportToolError(error, tool) {
|
|
203
|
+
captureException(error, { tags: { tool }, level: error instanceof TransportError ? 'warning' : 'error' });
|
|
204
|
+
}
|
|
205
|
+
function jsonRpcError(status, code, message) {
|
|
206
|
+
return new Response(JSON.stringify({ jsonrpc: '2.0', error: { code, message }, id: null }), {
|
|
207
|
+
status,
|
|
208
|
+
headers: { 'Content-Type': 'application/json' }
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
/** Sends this request's analytics: on Workers it goes into waitUntil and the response does not wait; Node has no executionCtx, so it fires and forgets. */
|
|
212
|
+
function flushInBackground(c, tracker) {
|
|
213
|
+
const flushing = tracker.flush().catch((error) => {
|
|
214
|
+
console.error('posthog flush failed:', error);
|
|
215
|
+
captureException(error, { level: 'warning' });
|
|
216
|
+
});
|
|
217
|
+
try {
|
|
218
|
+
c.executionCtx.waitUntil(flushing);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// Node: no executionCtx; sending already started above
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
//# sourceMappingURL=app.js.map
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication in HTTP mode: this service is only an OAuth Resource Server.
|
|
3
|
+
*
|
|
4
|
+
* - The authorization server is account.jinshuju.net (dynamic registration / PKCE / refresh supported); this service
|
|
5
|
+
* issues no tokens;
|
|
6
|
+
* - the request's Bearer (an OAuth token or a personal / account access token) goes to API v1 as is;
|
|
7
|
+
* - the API decides whether a token is valid: one light call (GET /me) checks it, and the result is cached by token
|
|
8
|
+
* hash for a while, so every tool call does not spend an extra API request;
|
|
9
|
+
* - with no token or an invalid one, answers 401 + WWW-Authenticate: Bearer resource_metadata=..., and the client
|
|
10
|
+
* goes through OAuth from there.
|
|
11
|
+
*/
|
|
12
|
+
import { type AuthInfo, type OAuthMetadata } from '@modelcontextprotocol/server';
|
|
13
|
+
import type { JinshujuApi } from './api.js';
|
|
14
|
+
export interface VerifierOptions {
|
|
15
|
+
api: JinshujuApi;
|
|
16
|
+
cacheTtlMs: number;
|
|
17
|
+
maxEntries?: number;
|
|
18
|
+
now?: () => number;
|
|
19
|
+
}
|
|
20
|
+
/** Token hash: the key of the verification cache, and the source of the analytics session id. */
|
|
21
|
+
export declare function sha256(text: string): Promise<string>;
|
|
22
|
+
export declare class TokenVerifier {
|
|
23
|
+
private readonly options;
|
|
24
|
+
private readonly cache;
|
|
25
|
+
private readonly now;
|
|
26
|
+
constructor(options: VerifierOptions);
|
|
27
|
+
/** Returns AuthInfo when valid, undefined when not. A network failure counts as invalid but is not cached. */
|
|
28
|
+
verify(token: string): Promise<AuthInfo | undefined>;
|
|
29
|
+
forget(token: string): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
/** The Jinshuju user id in AuthInfo (the id from GET /me), undefined when no user was identified; analytics and Sentry both identify the user by it. */
|
|
32
|
+
export declare function userIdOf(authInfo: AuthInfo | undefined): string | undefined;
|
|
33
|
+
export declare function bearerToken(request: Request): string | undefined;
|
|
34
|
+
export declare function challenge(resourceMetadataUrl: string, message: string): Response;
|
|
35
|
+
/** Fetches RFC 8414 metadata from the authorization server, falling back to built-in known values. */
|
|
36
|
+
export declare function loadAuthServerMetadata(authServer: string, fetchImpl?: typeof fetch): Promise<OAuthMetadata>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication in HTTP mode: this service is only an OAuth Resource Server.
|
|
3
|
+
*
|
|
4
|
+
* - The authorization server is account.jinshuju.net (dynamic registration / PKCE / refresh supported); this service
|
|
5
|
+
* issues no tokens;
|
|
6
|
+
* - the request's Bearer (an OAuth token or a personal / account access token) goes to API v1 as is;
|
|
7
|
+
* - the API decides whether a token is valid: one light call (GET /me) checks it, and the result is cached by token
|
|
8
|
+
* hash for a while, so every tool call does not spend an extra API request;
|
|
9
|
+
* - with no token or an invalid one, answers 401 + WWW-Authenticate: Bearer resource_metadata=..., and the client
|
|
10
|
+
* goes through OAuth from there.
|
|
11
|
+
*/
|
|
12
|
+
import { bearerAuthChallengeResponse, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
|
|
13
|
+
/** Token hash: the key of the verification cache, and the source of the analytics session id. */
|
|
14
|
+
export async function sha256(text) {
|
|
15
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
16
|
+
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
17
|
+
}
|
|
18
|
+
export class TokenVerifier {
|
|
19
|
+
options;
|
|
20
|
+
cache = new Map();
|
|
21
|
+
now;
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.options = options;
|
|
24
|
+
this.now = options.now ?? Date.now;
|
|
25
|
+
}
|
|
26
|
+
/** Returns AuthInfo when valid, undefined when not. A network failure counts as invalid but is not cached. */
|
|
27
|
+
async verify(token) {
|
|
28
|
+
const key = await sha256(token);
|
|
29
|
+
const hit = this.cache.get(key);
|
|
30
|
+
if (hit && hit.until > this.now())
|
|
31
|
+
return hit.info;
|
|
32
|
+
this.cache.delete(key);
|
|
33
|
+
const res = await this.options.api.raw('GET', '/me', token);
|
|
34
|
+
// 401 = the token itself is refused; any other status (200 / 402 / 403 for insufficient scope …) means the token is valid and only what it may do differs.
|
|
35
|
+
if (res.status === 401)
|
|
36
|
+
return undefined;
|
|
37
|
+
const me = (res.status === 200 ? res.json : undefined);
|
|
38
|
+
const info = {
|
|
39
|
+
token,
|
|
40
|
+
clientId: 'jinshuju',
|
|
41
|
+
scopes: [],
|
|
42
|
+
extra: me ? { userId: me.id, userName: me.name } : undefined
|
|
43
|
+
};
|
|
44
|
+
if (this.cache.size >= (this.options.maxEntries ?? 2000)) {
|
|
45
|
+
const oldest = this.cache.keys().next().value;
|
|
46
|
+
if (oldest !== undefined)
|
|
47
|
+
this.cache.delete(oldest);
|
|
48
|
+
}
|
|
49
|
+
this.cache.set(key, { info, until: this.now() + this.options.cacheTtlMs });
|
|
50
|
+
return info;
|
|
51
|
+
}
|
|
52
|
+
async forget(token) {
|
|
53
|
+
this.cache.delete(await sha256(token));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** The Jinshuju user id in AuthInfo (the id from GET /me), undefined when no user was identified; analytics and Sentry both identify the user by it. */
|
|
57
|
+
export function userIdOf(authInfo) {
|
|
58
|
+
const id = authInfo?.extra?.userId;
|
|
59
|
+
return id === undefined || id === null || id === '' ? undefined : String(id);
|
|
60
|
+
}
|
|
61
|
+
export function bearerToken(request) {
|
|
62
|
+
const header = request.headers.get('authorization');
|
|
63
|
+
const match = header && /^Bearer\s+(.+)$/i.exec(header.trim());
|
|
64
|
+
return match ? match[1].trim() : undefined;
|
|
65
|
+
}
|
|
66
|
+
export function challenge(resourceMetadataUrl, message) {
|
|
67
|
+
return bearerAuthChallengeResponse(new OAuthError(OAuthErrorCode.InvalidToken, message), { resourceMetadataUrl });
|
|
68
|
+
}
|
|
69
|
+
/** Fetches RFC 8414 metadata from the authorization server, falling back to built-in known values. */
|
|
70
|
+
export async function loadAuthServerMetadata(authServer, fetchImpl = fetch) {
|
|
71
|
+
try {
|
|
72
|
+
const res = await fetchImpl(`${authServer}/.well-known/oauth-authorization-server`, {
|
|
73
|
+
headers: { Accept: 'application/json' }
|
|
74
|
+
});
|
|
75
|
+
if (res.ok)
|
|
76
|
+
return (await res.json());
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// fall through to the built-in values below
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
issuer: authServer,
|
|
83
|
+
authorization_endpoint: `${authServer}/oauth/authorize`,
|
|
84
|
+
token_endpoint: `${authServer}/oauth/token`,
|
|
85
|
+
revocation_endpoint: `${authServer}/oauth/revoke`,
|
|
86
|
+
registration_endpoint: `${authServer}/oauth/register`,
|
|
87
|
+
scopes_supported: ['public', 'profile', 'forms', 'read_entries', 'write_entries', 'form_setting', 'users'],
|
|
88
|
+
response_types_supported: ['code'],
|
|
89
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
90
|
+
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post', 'none'],
|
|
91
|
+
code_challenge_methods_supported: ['S256']
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=auth.js.map
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Credential } from './credential.js';
|
|
2
|
+
export declare const DEFAULT_CONFIG_PATH: string;
|
|
3
|
+
export declare class CliSessionCredential implements Credential {
|
|
4
|
+
private readonly path;
|
|
5
|
+
private readonly authHost;
|
|
6
|
+
private readonly fetchImpl;
|
|
7
|
+
private config?;
|
|
8
|
+
private refreshing?;
|
|
9
|
+
constructor(path?: string, authHost?: string, fetchImpl?: typeof fetch);
|
|
10
|
+
static exists(path?: string): Promise<boolean>;
|
|
11
|
+
private load;
|
|
12
|
+
private session;
|
|
13
|
+
describe(): string;
|
|
14
|
+
token(): Promise<string>;
|
|
15
|
+
invalidate(): Promise<boolean>;
|
|
16
|
+
private refresh;
|
|
17
|
+
private doRefresh;
|
|
18
|
+
private save;
|
|
19
|
+
}
|
|
20
|
+
/** For the stdio entry: picks a credential source by priority. */
|
|
21
|
+
export declare function resolveCredential(env?: Record<string, string | undefined>): Promise<Credential>;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The local credential for stdio mode, found the way @jinshuju/cli does. The JINSHUJU_ACCESS_TOKEN environment variable
|
|
3
|
+
* comes first, then the credential `jinshuju auth login` saved in ~/.jinshuju/config.json (an access token or an OAuth
|
|
4
|
+
* session; an expired session is renewed with refresh_token and written back, mode 600). Both tools share one login.
|
|
5
|
+
*/
|
|
6
|
+
import { readFile, writeFile, rename, mkdir } from 'node:fs/promises';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { MissingCredentialError, StaticCredential } from './credential.js';
|
|
10
|
+
export const DEFAULT_CONFIG_PATH = join(homedir(), '.jinshuju', 'config.json');
|
|
11
|
+
const DEFAULT_CLIENT_ID = 'jinshuju_cli_public';
|
|
12
|
+
export class CliSessionCredential {
|
|
13
|
+
path;
|
|
14
|
+
authHost;
|
|
15
|
+
fetchImpl;
|
|
16
|
+
config;
|
|
17
|
+
refreshing;
|
|
18
|
+
constructor(path = DEFAULT_CONFIG_PATH, authHost = 'https://account.jinshuju.net', fetchImpl = fetch) {
|
|
19
|
+
this.path = path;
|
|
20
|
+
this.authHost = authHost;
|
|
21
|
+
this.fetchImpl = fetchImpl;
|
|
22
|
+
}
|
|
23
|
+
static async exists(path = DEFAULT_CONFIG_PATH) {
|
|
24
|
+
try {
|
|
25
|
+
await readFile(path, 'utf8');
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async load() {
|
|
33
|
+
if (!this.config)
|
|
34
|
+
this.config = JSON.parse(await readFile(this.path, 'utf8'));
|
|
35
|
+
return this.config;
|
|
36
|
+
}
|
|
37
|
+
session(config) {
|
|
38
|
+
const auth = config.auth;
|
|
39
|
+
return auth && auth.type === 'oauth' ? auth : undefined;
|
|
40
|
+
}
|
|
41
|
+
describe() {
|
|
42
|
+
const session = this.config && this.session(this.config);
|
|
43
|
+
return session ? `OAuth session (${this.path})` : `access token (${this.path})`;
|
|
44
|
+
}
|
|
45
|
+
async token() {
|
|
46
|
+
const config = await this.load();
|
|
47
|
+
const session = this.session(config);
|
|
48
|
+
if (session) {
|
|
49
|
+
const expiresAt = session.expires_at ? Date.parse(session.expires_at) : Number.NaN;
|
|
50
|
+
// Renew a minute early so the token does not expire mid-request
|
|
51
|
+
if (Number.isFinite(expiresAt) && expiresAt - Date.now() < 60_000 && session.refresh_token) {
|
|
52
|
+
await this.refresh();
|
|
53
|
+
}
|
|
54
|
+
return this.session(this.config).access_token;
|
|
55
|
+
}
|
|
56
|
+
const token = config.auth?.access_token ?? config.access_token;
|
|
57
|
+
if (!token)
|
|
58
|
+
throw new MissingCredentialError();
|
|
59
|
+
return token;
|
|
60
|
+
}
|
|
61
|
+
async invalidate() {
|
|
62
|
+
const config = await this.load();
|
|
63
|
+
if (!this.session(config)?.refresh_token)
|
|
64
|
+
return false;
|
|
65
|
+
return this.refresh();
|
|
66
|
+
}
|
|
67
|
+
refresh() {
|
|
68
|
+
// Concurrent tool calls share one renewal
|
|
69
|
+
this.refreshing ??= this.doRefresh().finally(() => {
|
|
70
|
+
this.refreshing = undefined;
|
|
71
|
+
});
|
|
72
|
+
return this.refreshing;
|
|
73
|
+
}
|
|
74
|
+
async doRefresh() {
|
|
75
|
+
const config = await this.load();
|
|
76
|
+
const session = this.session(config);
|
|
77
|
+
if (!session?.refresh_token)
|
|
78
|
+
return false;
|
|
79
|
+
const host = session.auth_host ?? config.auth_host ?? this.authHost;
|
|
80
|
+
const clientId = session.client_id ?? config.client_id ?? DEFAULT_CLIENT_ID;
|
|
81
|
+
const res = await this.fetchImpl(new URL('/oauth/token', host), {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
|
|
84
|
+
body: new URLSearchParams({
|
|
85
|
+
grant_type: 'refresh_token',
|
|
86
|
+
refresh_token: session.refresh_token,
|
|
87
|
+
client_id: clientId
|
|
88
|
+
})
|
|
89
|
+
});
|
|
90
|
+
if (!res.ok)
|
|
91
|
+
return false;
|
|
92
|
+
const body = (await res.json());
|
|
93
|
+
const next = {
|
|
94
|
+
...session,
|
|
95
|
+
access_token: body.access_token,
|
|
96
|
+
refresh_token: body.refresh_token ?? session.refresh_token,
|
|
97
|
+
expires_at: body.expires_in ? new Date(Date.now() + body.expires_in * 1000).toISOString() : session.expires_at,
|
|
98
|
+
scope: body.scope ?? session.scope
|
|
99
|
+
};
|
|
100
|
+
this.config = { ...config, auth: next };
|
|
101
|
+
await this.save(this.config);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
async save(config) {
|
|
105
|
+
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
106
|
+
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
107
|
+
await writeFile(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
108
|
+
await rename(tmp, this.path);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** For the stdio entry: picks a credential source by priority. */
|
|
112
|
+
export async function resolveCredential(env = process.env) {
|
|
113
|
+
if (env.JINSHUJU_ACCESS_TOKEN)
|
|
114
|
+
return new StaticCredential(env.JINSHUJU_ACCESS_TOKEN, 'env JINSHUJU_ACCESS_TOKEN');
|
|
115
|
+
const path = env.JINSHUJU_CONFIG ?? DEFAULT_CONFIG_PATH;
|
|
116
|
+
if (await CliSessionCredential.exists(path))
|
|
117
|
+
return new CliSessionCredential(path, env.JINSHUJU_AUTH_HOST);
|
|
118
|
+
throw new MissingCredentialError();
|
|
119
|
+
}
|
|
120
|
+
//# sourceMappingURL=cli-credential.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Composite tools: one call sends two requests: creating and editing exam / evaluation forms, and counting across forms.
|
|
3
|
+
* The generator assembles their input schema from openapi; this file only orchestrates: which request goes first, and what
|
|
4
|
+
* to say when one fails.
|
|
5
|
+
*/
|
|
6
|
+
import type { CallToolResult } from '@modelcontextprotocol/server';
|
|
7
|
+
import { type ToolRuntime } from './tools.js';
|
|
8
|
+
import type { OperationSpec, ToolSpec } from './types.js';
|
|
9
|
+
export declare const OPERATIONS: Record<string, OperationSpec>;
|
|
10
|
+
type Args = Record<string, unknown>;
|
|
11
|
+
export declare function runComposite(tool: ToolSpec, args: Args, runtime: ToolRuntime): Promise<CallToolResult>;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { toToolResult } from './tools.js';
|
|
2
|
+
import operationsJson from './generated/operations.json' with { type: 'json' };
|
|
3
|
+
export const OPERATIONS = operationsJson;
|
|
4
|
+
function op(id) {
|
|
5
|
+
const spec = OPERATIONS[id];
|
|
6
|
+
if (!spec)
|
|
7
|
+
throw new Error(`${id} is not in operations.json`);
|
|
8
|
+
return spec;
|
|
9
|
+
}
|
|
10
|
+
function ok(res) {
|
|
11
|
+
return res.status >= 200 && res.status < 300;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Arguments of the scene setting operation: an operation with a flattened body takes the setting keys at the top level;
|
|
15
|
+
* one whose body is a single schema (such as updateEvaluationSetting's EvaluationSettingInput pointer) takes them under
|
|
16
|
+
* the body.param key. Both follow the operation table; nothing is guessed here.
|
|
17
|
+
*/
|
|
18
|
+
function settingArgs(settingOp, formToken, setting) {
|
|
19
|
+
if (!settingOp.body || settingOp.body.flatten)
|
|
20
|
+
return { form_token: formToken, ...setting };
|
|
21
|
+
return { form_token: formToken, [settingOp.body.param ?? 'body']: setting };
|
|
22
|
+
}
|
|
23
|
+
function withText(result, text) {
|
|
24
|
+
const first = result.content[0];
|
|
25
|
+
if (first?.type === 'text')
|
|
26
|
+
first.text = `${text}\n${first.text}`;
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
export async function runComposite(tool, args, runtime) {
|
|
30
|
+
const { api, credential, maxResultChars } = runtime;
|
|
31
|
+
const composite = tool.composite;
|
|
32
|
+
if (composite.kind === 'count_entries') {
|
|
33
|
+
const tokens = args.form_token;
|
|
34
|
+
if (Array.isArray(tokens)) {
|
|
35
|
+
const { form_token: _ignored, ...rest } = args;
|
|
36
|
+
const res = await api.call(op('countEntriesAcrossForms'), { ...rest, form_tokens: tokens.join(',') }, credential);
|
|
37
|
+
return toToolResult(tool, res, maxResultChars);
|
|
38
|
+
}
|
|
39
|
+
return toToolResult(tool, await api.call(op('countEntries'), args, credential), maxResultChars);
|
|
40
|
+
}
|
|
41
|
+
const settingKey = composite.settingKey;
|
|
42
|
+
const settingOp = op(composite.settingOperation);
|
|
43
|
+
const settingsUpdated = `${composite.scene === 'exam' ? 'Exam' : 'Evaluation'} settings updated.`;
|
|
44
|
+
const { [settingKey]: setting, ...formArgs } = args;
|
|
45
|
+
const hasSetting = !!setting && typeof setting === 'object' && Object.keys(setting).length > 0;
|
|
46
|
+
if (composite.kind === 'scene_form_create') {
|
|
47
|
+
// Create the form first (to get its token), then write the scene settings; if the settings are rejected the form already exists, and the result must say so, or the caller creates another one.
|
|
48
|
+
const created = await api.call(op('createForm'), { ...formArgs, scene: composite.scene }, credential);
|
|
49
|
+
if (!ok(created))
|
|
50
|
+
return toToolResult(tool, created, maxResultChars);
|
|
51
|
+
const token = created.json?.token;
|
|
52
|
+
if (!hasSetting || !token)
|
|
53
|
+
return toToolResult(tool, created, maxResultChars);
|
|
54
|
+
const updated = await api.call(settingOp, settingArgs(settingOp, token, setting), credential);
|
|
55
|
+
if (!ok(updated)) {
|
|
56
|
+
const failure = toToolResult(tool, updated, maxResultChars);
|
|
57
|
+
return withText(failure, `The form was created (token=${token}), but its ${composite.scene} settings were rejected; retry ${settingKey} alone with edit_${composite.scene}_form.`);
|
|
58
|
+
}
|
|
59
|
+
return withText(toToolResult(tool, created, maxResultChars), `${settingsUpdated} Form:`);
|
|
60
|
+
}
|
|
61
|
+
// scene_form_update: the settings go first (they are the half that may be rejected: a form not in that scene); if they are rejected, none of the other changes are sent.
|
|
62
|
+
const formToken = formArgs.form_token;
|
|
63
|
+
const hasFormChanges = Object.keys(formArgs).some((k) => k !== 'form_token' && formArgs[k] !== undefined);
|
|
64
|
+
let settingResult;
|
|
65
|
+
if (hasSetting) {
|
|
66
|
+
settingResult = await api.call(settingOp, settingArgs(settingOp, formToken, setting), credential);
|
|
67
|
+
if (!ok(settingResult))
|
|
68
|
+
return toToolResult(tool, settingResult, maxResultChars);
|
|
69
|
+
}
|
|
70
|
+
if (!hasFormChanges) {
|
|
71
|
+
return settingResult
|
|
72
|
+
? withText(toToolResult(tool, settingResult, maxResultChars), settingsUpdated)
|
|
73
|
+
: { isError: true, content: [{ type: 'text', text: `Nothing to change: pass ${settingKey} or form fields.` }] };
|
|
74
|
+
}
|
|
75
|
+
const updated = await api.call(op('updateForm'), formArgs, credential);
|
|
76
|
+
const result = toToolResult(tool, updated, maxResultChars);
|
|
77
|
+
if (settingResult) {
|
|
78
|
+
return withText(result, ok(updated) ? `${settingsUpdated} Form:` : `${settingsUpdated} The form changes were rejected:`);
|
|
79
|
+
}
|
|
80
|
+
return result;
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=composites.js.map
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime configuration. The HTTP deployment reads it from environment variables (Node) or the Workers env; stdio uses only the API-related part.
|
|
3
|
+
*/
|
|
4
|
+
export interface Config {
|
|
5
|
+
/** API v1 base URL, https://jinshuju.net/api/v1 by default */
|
|
6
|
+
apiBaseUrl: string;
|
|
7
|
+
/** OAuth authorization server (issuer), https://account.jinshuju.net by default */
|
|
8
|
+
authServer: string;
|
|
9
|
+
/**
|
|
10
|
+
* Public addresses of this service, possibly several (e.g. the custom domain https://mcp.jinshuju.net and the platform's
|
|
11
|
+
* default prefixed address https://host/jinshuju-mcp). One is picked by the request's Host, falling back to the first;
|
|
12
|
+
* when empty, each request's X-Forwarded-* / Host decides.
|
|
13
|
+
*/
|
|
14
|
+
publicUrls: string[];
|
|
15
|
+
/** Most characters one tool result returns; beyond it the result is truncated with a hint to narrow it */
|
|
16
|
+
maxResultChars: number;
|
|
17
|
+
/** How long token verification results are cached in HTTP mode (ms) */
|
|
18
|
+
tokenCacheTtlMs: number;
|
|
19
|
+
/** Timeout of API calls (ms) */
|
|
20
|
+
timeoutMs: number;
|
|
21
|
+
/** PostHog project token (phc_…) for MCP analytics in HTTP mode; nothing is reported when empty */
|
|
22
|
+
posthogApiKey?: string;
|
|
23
|
+
/** PostHog ingest host, https://us.i.posthog.com by default */
|
|
24
|
+
posthogHost: string;
|
|
25
|
+
/** Whether to collect the call intent the model writes (each tool's context argument → $mcp_intent); on by default */
|
|
26
|
+
collectIntent: boolean;
|
|
27
|
+
}
|
|
28
|
+
export declare const DEFAULTS: Config;
|
|
29
|
+
type EnvLike = Record<string, string | undefined>;
|
|
30
|
+
/** JINSHUJU_MCP_PUBLIC_URL: several comma-separated addresses, with trailing slashes and empty items removed. */
|
|
31
|
+
export declare function publicUrlsFromEnv(value: string | undefined): string[];
|
|
32
|
+
/** Reads the configuration from environment variables; keys carry the JINSHUJU_ prefix, as in @jinshuju/cli. */
|
|
33
|
+
export declare function configFromEnv(env?: EnvLike): Config;
|
|
34
|
+
export {};
|