@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 ADDED
@@ -0,0 +1,17 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ Copyright 2026 Jinshuju (金数据)
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License");
8
+ you may not use this file except in compliance with the License.
9
+ You may obtain a copy of the License at
10
+
11
+ http://www.apache.org/licenses/LICENSE-2.0
12
+
13
+ Unless required by applicable law or agreed to in writing, software
14
+ distributed under the License is distributed on an "AS IS" BASIS,
15
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16
+ See the License for the specific language governing permissions and
17
+ limitations under the License.
package/README.md CHANGED
@@ -1,3 +1,88 @@
1
- # Temporary Holding Version
1
+ # @jinshuju/mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The MCP server for Jinshuju ([jinshuju.net](https://jinshuju.net)): lets Claude, Cursor, Claude Code and other MCP-capable AI tools read and write forms, tables and entries.
4
+ The tools are generated from the [open API v1 openapi.yaml](https://jinshuju.net/api/v1/openapi.yaml) and stay in step with the API; the tool set matches the tools documented for the [Jinshuju MCP Server](https://open.jinshuju.net/mcp) one to one.
5
+
6
+ Two ways to use it:
7
+
8
+ - **Remote (HTTP)** — the official deployment at `https://jinshuju.net/mcp`, with OAuth sign-in; for claude.ai, Claude Code, Cursor, Windsurf and the like;
9
+ - **Local (stdio)** — `npx -y @jinshuju/mcp` with an access token; for clients that only run local servers.
10
+
11
+ ## Remote use
12
+
13
+ Server URL: `https://jinshuju.net/mcp`
14
+
15
+ | Client | Setup |
16
+ | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
17
+ | claude.ai | Settings → Connectors → Add custom connector, enter the server URL, sign in to Jinshuju |
18
+ | Claude Code | `claude mcp add jinshuju -s user --transport http https://jinshuju.net/mcp`, then run `/mcp` in Claude Code and sign in to Jinshuju there |
19
+ | Cursor | `{ "mcpServers": { "jinshuju": { "url": "https://jinshuju.net/mcp" } } }` |
20
+ | Windsurf | Same as above, with the key `serverUrl` |
21
+
22
+ Without OAuth (CI, scripts), send an access token directly: `--header "Authorization: Bearer YOUR_ACCESS_TOKEN"`, or add `headers` to the config.
23
+ Create a personal access token under [Profile → API](https://next.jinshuju.net/profile/api), and an account access token under [System settings → Account API](https://next.jinshuju.net/system/api_licence).
24
+
25
+ ## Local use (stdio)
26
+
27
+ Pass an access token (created as above) in `JINSHUJU_ACCESS_TOKEN`:
28
+
29
+ ```bash
30
+ # Claude Code
31
+ claude mcp add jinshuju -s user -e JINSHUJU_ACCESS_TOKEN=your_token -- npx -y @jinshuju/mcp
32
+ ```
33
+
34
+ Other clients:
35
+
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "jinshuju": {
40
+ "command": "npx",
41
+ "args": ["-y", "@jinshuju/mcp"],
42
+ "env": { "JINSHUJU_ACCESS_TOKEN": "your_token" }
43
+ }
44
+ }
45
+ }
46
+ ```
47
+
48
+ To check it works, ask the assistant to "list my Jinshuju forms".
49
+
50
+ ## Tools
51
+
52
+ 60 tools with the same names as those documented for the [Jinshuju MCP Server](https://open.jinshuju.net/mcp). Each tool maps to one operation in openapi.yaml; a few combine two operations:
53
+
54
+ | Category | Tools |
55
+ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
56
+ | Forms | list_forms, get_form, create_form, edit_form, copy_form, move_form, list_form_cooperators, get_field_rules, edit_field_rules, check_field_data, preview_field_type_change, edit_theme |
57
+ | Exams / evaluations | create_exam_form, edit_exam_form, create_evaluation_form, edit_evaluation_form (create / edit the form + its scene settings, two requests) |
58
+ | Entries | list_entries, get_entry, create_entry, update_entry, delete_entry, create_entries, patch_entries, count_entries, aggregate_entries, get_form_data_summary, search_entries_in_forms, list_form_entry_stats, import_entries_from_file |
59
+ | What I submitted | list_my_submitted_forms, list_my_submitted_entries, search_my_submitted_entries |
60
+ | Comments | list_entry_comments, create_entry_comment, update_entry_comment, delete_entry_comment |
61
+ | Views | list_form_views, get_form_view, create_form_view, edit_form_view, delete_form_view, list_form_view_entries |
62
+ | Tables | list_tables, get_table, create_table, edit_table, move_table |
63
+ | Public queries | list_opensearch_queries, get_opensearch_query, create_opensearch_query, edit_opensearch_query, get_opensearch_field_suggestions |
64
+ | Folders / account | list_folders, create_folder, get_current_user, get_current_billing_account, list_account_users |
65
+ | Uploads | upload_entry_attachment, upload_form_image, upload_import_file |
66
+ | Schemas | get_schema (reads an openapi schema by name, e.g. FieldInput, TextFieldInput) |
67
+
68
+ The full tool → operation mapping is in `mcp.overlay.yaml` and `src/generated/meta.json`.
69
+
70
+ **Size.** The full tools/list is about 39k tokens (o200k estimate; about 36k without outputSchema).
71
+ It stays that size by not inlining large schemas: field definitions (52 types), form settings, themes and the like become a one-line pointer; unions that branch on type, such as table columns, keep only their common properties.
72
+ The model reads them on demand with `get_schema("FieldInput")` / `get_schema("TextFieldInput")`.
73
+
74
+ ## Environment variables (stdio)
75
+
76
+ | Variable | Meaning |
77
+ | ------------------------------- | -------------------------------------------------------------------------------------------------- |
78
+ | `JINSHUJU_ACCESS_TOKEN` | Access token (personal or account) |
79
+ | `JINSHUJU_API_BASE_URL` | API URL, default `https://jinshuju.net/api/v1` |
80
+ | `JINSHUJU_MCP_MAX_RESULT_CHARS` | Maximum characters of one result, default 120000; longer results are truncated with a hint to page |
81
+
82
+ ## Development
83
+
84
+ Building, running and deploying the server: [DEVELOPMENT.md](DEVELOPMENT.md). How it fits together: [ARCHITECTURE.md](ARCHITECTURE.md).
85
+
86
+ ## License
87
+
88
+ Apache-2.0
@@ -0,0 +1,84 @@
1
+ /**
2
+ * PostHog MCP analytics (@posthog/mcp): the HTTP deployment reports how the tools are used. stdio runs on the user's
3
+ * own machine and reports nothing.
4
+ *
5
+ * What is captured:
6
+ * - only $mcp_initialize and $mcp_tool_call; tools/list, resources/*, $identify and $exception are all dropped;
7
+ * - a tool call records only argument names ($mcp_input_keys), never argument values or the result; a failure records
8
+ * the truncated error text, its bucket ($mcp_error_type: validation / permission / timeout / rate_limited / api_4xx /
9
+ * api_5xx / internal, read off the text because the SDK types only thrown exceptions and our tools return isError
10
+ * results) and, for an API failure, the HTTP status ($mcp_error_status);
11
+ * - mcp_caller_type tells Jiri's remaining in-house path (client name mcp, python-httpx user agent) from everyone else (external);
12
+ * - intent: every tool gets a context argument where the model says what the user wants to do ($mcp_intent). The SDK
13
+ * scrubs emails / phone numbers / IPs / card numbers from it before sending; JINSHUJU_MCP_COLLECT_INTENT=false turns
14
+ * it off entirely (the argument is no longer offered and nothing is reported);
15
+ * - distinct_id = the Jinshuju user id (from GET /me); $session_id is derived from the token hash: one session per token;
16
+ * - a session's initialize is reported at most once a day (deduplicated in process; best effort across Workers instances).
17
+ *
18
+ * Every other SDK injection stays off: no llm_model / conversation_id arguments, no get_more_tools / send_feedback tools.
19
+ *
20
+ * The PostHog client is created per request and shut down when the request ends, never shared across requests:
21
+ * posthog-node chains each flush onto the previous one, and in Workers a flush still pending when its request's context
22
+ * ends never settles. A shared client would hang every later request's reporting on that chain (the runtime reports
23
+ * "code had hung" and not a single event goes out).
24
+ */
25
+ import type { AuthInfo, McpServer } from '@modelcontextprotocol/server';
26
+ import type { Config } from './config.js';
27
+ /** The question the model answers in the context argument; it becomes $mcp_intent. */
28
+ export declare const CONTEXT_DESCRIPTION = "Why are you calling this tool? Briefly describe the user's goal.";
29
+ export declare const CONTEXT_INSTRUCTIONS: string;
30
+ /** The buckets PostHog's own MCP analytics uses for $mcp_error_type, so its dashboards read our events directly. */
31
+ export type ErrorType = 'validation' | 'permission' | 'timeout' | 'rate_limited' | 'api_4xx' | 'api_5xx' | 'internal';
32
+ /**
33
+ * Buckets a failed tool call by its text, whose shapes are all ours: result.ts opens an API failure with `HTTP <status>:`
34
+ * and closes it with a hint, api.ts opens a TransportError with the reason, and the MCP SDK prefixes a validation failure.
35
+ * Anything else is an exception a tool threw, which is a bug of ours.
36
+ */
37
+ export declare function classifyError(message: string): {
38
+ type: ErrorType;
39
+ status?: number;
40
+ };
41
+ /**
42
+ * Who is calling: `jiri` for the in-house assistant's remaining path through this server, which runs on the Python MCP
43
+ * SDK's defaults (client name mcp, python-httpx user agent) and nothing else does; `external` for everyone else. The
44
+ * retired built-in server reported the same two values.
45
+ */
46
+ export declare function callerTypeOf(clientName: unknown, userAgent: unknown): 'jiri' | 'external';
47
+ export interface AnalyticsOptions {
48
+ /** PostHog project token (phc_…) */
49
+ apiKey: string;
50
+ /** PostHog ingest host */
51
+ host: string;
52
+ /** Whether to offer the context argument and report $mcp_intent; on by default */
53
+ collectIntent?: boolean;
54
+ /** For tests: the fetch used for reporting */
55
+ fetch?: typeof fetch;
56
+ now?: () => number;
57
+ /** Timeout of one request to PostHog (ms), 5 seconds by default; for tests */
58
+ requestTimeoutMs?: number;
59
+ }
60
+ /** The analytics handle of one request. */
61
+ export interface RequestAnalytics {
62
+ /** Sends the events this request buffered and closes the client. On Workers it goes into waitUntil; on Node there is no need to wait. */
63
+ flush(): Promise<void>;
64
+ }
65
+ export declare class McpAnalytics {
66
+ readonly collectIntent: boolean;
67
+ private readonly options;
68
+ private readonly handshakes;
69
+ private readonly inflight;
70
+ private readonly now;
71
+ constructor(options: AnalyticsOptions);
72
+ /** Instruments this request's McpServer: the user, the session and the PostHog client are all closed over in this one request. */
73
+ instrument(server: McpServer, authInfo?: AuthInfo): Promise<RequestAnalytics>;
74
+ private track;
75
+ private beforeSend;
76
+ /** Clients handshake again and again on one connection, and every later handshake reports what the first one already did. */
77
+ private repeatedHandshake;
78
+ /** Waits until every request's reporting still in flight has been sent: used before the Node process exits, and in tests. */
79
+ flush(): Promise<void>;
80
+ /** Called before the process exits: same as flush. */
81
+ shutdown(): Promise<void>;
82
+ }
83
+ /** Reports only when POSTHOG_API_KEY is set. */
84
+ export declare function analyticsFromConfig(cfg: Config, fetchImpl?: typeof fetch): McpAnalytics | undefined;
@@ -0,0 +1,192 @@
1
+ import { deriveSessionIdFromMCPSession, instrument } from '@posthog/mcp';
2
+ import { PostHog } from 'posthog-node';
3
+ import { sha256, userIdOf } from './auth.js';
4
+ import { FORBIDDEN_HINT } from './result.js';
5
+ import { TOOLS } from './tools.js';
6
+ import { CONTEXT_ARGUMENT } from './validation.js';
7
+ /** The question the model answers in the context argument; it becomes $mcp_intent. */
8
+ export const CONTEXT_DESCRIPTION = "Why are you calling this tool? Briefly describe the user's goal.";
9
+ export const CONTEXT_INSTRUCTIONS = `Every tool takes a \`${CONTEXT_ARGUMENT}\` argument: say what the user is actually trying to get done, ` +
10
+ 'in their own words, rather than restating what the tool does — it is what tells us which needs these tools ' +
11
+ 'are being asked to serve. Leave personal details (phone numbers, ID numbers, addresses) out of it.';
12
+ const KEPT_EVENTS = new Set(['$mcp_initialize', '$mcp_tool_call']);
13
+ /** By default the SDK attaches the (scrubbed) arguments and result; only argument names are recorded, so these go entirely. */
14
+ const DROPPED_PROPERTIES = ['$mcp_parameters', '$mcp_response', '$mcp_tool_description'];
15
+ const HANDSHAKE_TTL_MS = 24 * 60 * 60_000;
16
+ const HANDSHAKE_MAX_ENTRIES = 2000;
17
+ /** Time limit for sending the buffered events when a request ends: covers one request plus one retry */
18
+ const SHUTDOWN_TIMEOUT_MS = 15_000;
19
+ /**
20
+ * $mcp_input_keys records only argument names declared in openapi: the SDK cannot read the declarations fromJsonSchema
21
+ * produces, so it gets the full set here (every tool's argument names together, plus get_schema's name). Key names the
22
+ * model makes up are all recorded as [redacted].
23
+ */
24
+ const DECLARED_PARAMETER_NAMES = new Set([
25
+ 'name',
26
+ ...TOOLS.flatMap((tool) => Object.keys(tool.inputSchema.properties ?? {}))
27
+ ]);
28
+ /**
29
+ * An argument problem caught before any request goes out: the MCP SDK's prefix on a validation.ts failure, get_schema's
30
+ * unknown name, a composite tool given nothing to send, a path parameter left empty; and validation.ts's own phrasings,
31
+ * should the SDK ever stop prefixing them.
32
+ */
33
+ const VALIDATION_ERROR = /^(Input validation error|No schema named|Nothing to change|Missing path parameter|Unknown arguments)\b|Instance type|required property/;
34
+ /**
35
+ * Buckets a failed tool call by its text, whose shapes are all ours: result.ts opens an API failure with `HTTP <status>:`
36
+ * and closes it with a hint, api.ts opens a TransportError with the reason, and the MCP SDK prefixes a validation failure.
37
+ * Anything else is an exception a tool threw, which is a bug of ours.
38
+ */
39
+ export function classifyError(message) {
40
+ const http = /^HTTP (\d{3}):/.exec(message);
41
+ if (http) {
42
+ const status = Number(http[1]);
43
+ if (status === 429)
44
+ return { type: 'rate_limited', status };
45
+ if (status === 403 && /\bscope\b/i.test(serverErrorLines(message)))
46
+ return { type: 'permission', status };
47
+ return { type: status >= 500 ? 'api_5xx' : 'api_4xx', status };
48
+ }
49
+ if (VALIDATION_ERROR.test(message))
50
+ return { type: 'validation' };
51
+ if (/^Request timed out\b/.test(message))
52
+ return { type: 'timeout' };
53
+ return { type: 'internal' };
54
+ }
55
+ /** What the API itself said: the lines between result.ts's `HTTP <status>:` line and its hint (the 403 hint mentions scope itself). */
56
+ function serverErrorLines(message) {
57
+ return message
58
+ .split('\n')
59
+ .slice(1)
60
+ .filter((line) => line !== FORBIDDEN_HINT && !line.startsWith('⚠'))
61
+ .join('\n');
62
+ }
63
+ /**
64
+ * Who is calling: `jiri` for the in-house assistant's remaining path through this server, which runs on the Python MCP
65
+ * SDK's defaults (client name mcp, python-httpx user agent) and nothing else does; `external` for everyone else. The
66
+ * retired built-in server reported the same two values.
67
+ */
68
+ export function callerTypeOf(clientName, userAgent) {
69
+ return clientName === 'mcp' && typeof userAgent === 'string' && userAgent.startsWith('python-httpx/')
70
+ ? 'jiri'
71
+ : 'external';
72
+ }
73
+ /** Forwards the SDK's internal log to console only when something is wrong; drops the "Successfully set up…" every request prints. */
74
+ function warn(message) {
75
+ if (/^(Warning|Error|WARN)\b/.test(message))
76
+ console.warn(`[posthog-mcp] ${message}`);
77
+ }
78
+ export class McpAnalytics {
79
+ collectIntent;
80
+ options;
81
+ handshakes = new Map();
82
+ inflight = new Set();
83
+ now;
84
+ constructor(options) {
85
+ this.options = options;
86
+ this.collectIntent = options.collectIntent ?? true;
87
+ this.now = options.now ?? Date.now;
88
+ }
89
+ /** Instruments this request's McpServer: the user, the session and the PostHog client are all closed over in this one request. */
90
+ async instrument(server, authInfo) {
91
+ const sessionId = authInfo ? deriveSessionIdFromMCPSession(await sha256(authInfo.token)) : undefined;
92
+ const distinctId = userIdOf(authInfo);
93
+ const posthogOptions = {
94
+ host: this.options.host,
95
+ // Events are sent from the server, so the IP is this service's; GeoIP on it would set the user's location to the data center.
96
+ disableGeoip: true,
97
+ // One batch per request; a failed send is retried at most once, so it does not outlast the waitUntil limit.
98
+ requestTimeout: this.options.requestTimeoutMs ?? 5_000,
99
+ fetchRetryCount: 1,
100
+ fetchRetryDelay: 1_000,
101
+ fetch: this.options.fetch
102
+ };
103
+ const posthog = new PostHog(this.options.apiKey, posthogOptions);
104
+ // posthog-node logs a failed send only in debug mode; record it and throw it to the caller on flush (console + Sentry).
105
+ const failures = [];
106
+ posthog.on('error', (error) => failures.push(error));
107
+ instrument(server, posthog, {
108
+ context: this.collectIntent ? { description: CONTEXT_DESCRIPTION } : false,
109
+ captureModel: false,
110
+ enableConversationId: false,
111
+ enableExceptionAutocapture: false,
112
+ identify: distinctId ? { distinctId } : null,
113
+ shouldRecordInputKey: (key, { declared }) => declared || DECLARED_PARAMETER_NAMES.has(key),
114
+ beforeSend: this.beforeSend(sessionId),
115
+ logger: warn
116
+ });
117
+ return {
118
+ flush: () => this.track(posthog.shutdown(SHUTDOWN_TIMEOUT_MS).then(() => {
119
+ if (failures.length)
120
+ throw failures[0];
121
+ }))
122
+ };
123
+ }
124
+ track(flush) {
125
+ const tracked = flush.finally(() => this.inflight.delete(tracked));
126
+ this.inflight.add(tracked);
127
+ return tracked;
128
+ }
129
+ beforeSend(sessionId) {
130
+ return (event) => {
131
+ if (!KEPT_EVENTS.has(event.event))
132
+ return null;
133
+ const props = event.properties;
134
+ for (const key of DROPPED_PROPERTIES)
135
+ delete props[key];
136
+ if (sessionId) {
137
+ // The SDK opens a new session per request; replace it with the token-derived one. When no user was identified, distinct_id is the session id, so replace that too.
138
+ if (event.distinct_id === props.$session_id)
139
+ event.distinct_id = sessionId;
140
+ props.$session_id = sessionId;
141
+ }
142
+ if (event.event === '$mcp_initialize' && this.repeatedHandshake(String(props.$session_id ?? '')))
143
+ return null;
144
+ if (event.event === '$mcp_tool_call' && props.$mcp_is_error === true) {
145
+ // The SDK types a thrown exception by its class; an isError result is always "Error", so the bucket comes from the text.
146
+ const { type, status } = classifyError(String(props.$mcp_error_message ?? ''));
147
+ props.$mcp_error_type = type;
148
+ if (status !== undefined)
149
+ props.$mcp_error_status = status;
150
+ }
151
+ props.mcp_caller_type = callerTypeOf(props.$mcp_client_name, props.$mcp_client_user_agent);
152
+ return event;
153
+ };
154
+ }
155
+ /** Clients handshake again and again on one connection, and every later handshake reports what the first one already did. */
156
+ repeatedHandshake(sessionId) {
157
+ if (!sessionId)
158
+ return false;
159
+ const now = this.now();
160
+ const until = this.handshakes.get(sessionId);
161
+ if (until !== undefined && until > now)
162
+ return true;
163
+ this.handshakes.delete(sessionId);
164
+ if (this.handshakes.size >= HANDSHAKE_MAX_ENTRIES) {
165
+ const oldest = this.handshakes.keys().next().value;
166
+ if (oldest !== undefined)
167
+ this.handshakes.delete(oldest);
168
+ }
169
+ this.handshakes.set(sessionId, now + HANDSHAKE_TTL_MS);
170
+ return false;
171
+ }
172
+ /** Waits until every request's reporting still in flight has been sent: used before the Node process exits, and in tests. */
173
+ async flush() {
174
+ await Promise.allSettled(this.inflight);
175
+ }
176
+ /** Called before the process exits: same as flush. */
177
+ shutdown() {
178
+ return this.flush();
179
+ }
180
+ }
181
+ /** Reports only when POSTHOG_API_KEY is set. */
182
+ export function analyticsFromConfig(cfg, fetchImpl) {
183
+ if (!cfg.posthogApiKey)
184
+ return undefined;
185
+ return new McpAnalytics({
186
+ apiKey: cfg.posthogApiKey,
187
+ host: cfg.posthogHost,
188
+ collectIntent: cfg.collectIntent,
189
+ fetch: fetchImpl
190
+ });
191
+ }
192
+ //# sourceMappingURL=analytics.js.map
package/dist/api.d.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Sends one API v1 request for an OperationSpec. It only decides how to send it: URL, query, body, multipart,
3
+ * credential, timeout and response parsing. What to send comes from the generated operation table; how to report
4
+ * the result is up to tools.ts.
5
+ */
6
+ import type { Credential } from './credential.js';
7
+ import type { OperationSpec } from './types.js';
8
+ export interface ApiOptions {
9
+ baseUrl: string;
10
+ fetch?: typeof fetch;
11
+ timeoutMs?: number;
12
+ userAgent?: string;
13
+ }
14
+ export interface ApiResponse {
15
+ status: number;
16
+ /** The parsed JSON; undefined for a non-JSON or empty response */
17
+ json?: unknown;
18
+ /** The raw text of a non-JSON response (truncated) */
19
+ text?: string;
20
+ /** Argument problems the server reports in report mode (X-API-Input-Warnings) */
21
+ warnings?: string;
22
+ retryAfter?: string;
23
+ rateLimit?: {
24
+ limit?: string;
25
+ remaining?: string;
26
+ reset?: string;
27
+ };
28
+ }
29
+ export declare class TransportError extends Error {
30
+ constructor(message: string, options?: {
31
+ cause?: unknown;
32
+ });
33
+ }
34
+ type Args = Record<string, unknown>;
35
+ export declare function buildUrl(baseUrl: string, op: OperationSpec, args: Args): URL;
36
+ /** The request body: when flattened, every argument key except the path / query parameters; otherwise the value under body.param. */
37
+ export declare function bodyPayload(op: OperationSpec, args: Args): unknown;
38
+ export declare function buildBody(op: OperationSpec, args: Args): {
39
+ body?: BodyInit;
40
+ contentType?: string;
41
+ };
42
+ export declare class JinshujuApi {
43
+ private readonly options;
44
+ private readonly fetchImpl;
45
+ constructor(options: ApiOptions);
46
+ get baseUrl(): string;
47
+ /** Sends the request; on 401, has the credential renew once and retries. */
48
+ call(op: OperationSpec, args: Args, credential: Credential): Promise<ApiResponse>;
49
+ /** A raw request outside the operation table, for internal calls such as token verification. */
50
+ raw(method: string, path: string, token: string): Promise<ApiResponse>;
51
+ private send;
52
+ private perform;
53
+ }
54
+ export {};
package/dist/api.js ADDED
@@ -0,0 +1,167 @@
1
+ export class TransportError extends Error {
2
+ constructor(message, options) {
3
+ super(message, options);
4
+ this.name = 'TransportError';
5
+ }
6
+ }
7
+ export function buildUrl(baseUrl, op, args) {
8
+ let path = op.path;
9
+ for (const p of op.params) {
10
+ if (p.in !== 'path')
11
+ continue;
12
+ const value = args[p.name];
13
+ if (value === undefined || value === null || value === '')
14
+ throw new Error(`Missing path parameter ${p.name}`);
15
+ path = path.replace(`{${p.name}}`, encodeURIComponent(String(value).trim()));
16
+ }
17
+ const url = new URL(baseUrl.replace(/\/$/, '') + path);
18
+ for (const p of op.params) {
19
+ if (p.in !== 'query')
20
+ continue;
21
+ const value = args[p.name];
22
+ if (value === undefined || value === null || value === '')
23
+ continue;
24
+ if (Array.isArray(value) && !p.json) {
25
+ for (const item of value)
26
+ url.searchParams.append(`${p.name}[]`, scalar(item));
27
+ }
28
+ else {
29
+ // A `content: application/json` parameter goes as one value: the array as JSON text, a string (JSON text already) as is
30
+ url.searchParams.set(p.name, scalar(value));
31
+ }
32
+ }
33
+ return url;
34
+ }
35
+ function scalar(value) {
36
+ return typeof value === 'string' ? value : JSON.stringify(value);
37
+ }
38
+ function decodeBase64(data) {
39
+ const raw = data.startsWith('data:') && data.includes(',') ? data.slice(data.indexOf(',') + 1) : data;
40
+ const bin = atob(raw.replace(/\s+/g, ''));
41
+ const bytes = new Uint8Array(bin.length);
42
+ for (let i = 0; i < bin.length; i++)
43
+ bytes[i] = bin.charCodeAt(i);
44
+ return bytes;
45
+ }
46
+ /** The request body: when flattened, every argument key except the path / query parameters; otherwise the value under body.param. */
47
+ export function bodyPayload(op, args) {
48
+ if (!op.body)
49
+ return undefined;
50
+ let payload;
51
+ if (op.body.flatten) {
52
+ const rest = {};
53
+ for (const [key, value] of Object.entries(args)) {
54
+ if (!op.paramNames.includes(key) && value !== undefined)
55
+ rest[key] = value;
56
+ }
57
+ payload = rest;
58
+ }
59
+ else {
60
+ payload = args[op.body.param ?? 'body'];
61
+ }
62
+ if (payload === undefined)
63
+ return undefined;
64
+ return op.body.wrap ? { [op.body.wrap]: payload } : payload;
65
+ }
66
+ export function buildBody(op, args) {
67
+ const payload = bodyPayload(op, args);
68
+ if (!op.body || payload === undefined)
69
+ return {};
70
+ if (op.body.contentType === 'application/json') {
71
+ return { body: JSON.stringify(payload), contentType: 'application/json' };
72
+ }
73
+ const fields = payload;
74
+ const form = new FormData();
75
+ for (const [key, value] of Object.entries(fields)) {
76
+ if (key === 'file_base64' || key === 'file_name' || key === 'content_type')
77
+ continue;
78
+ if (value === undefined || value === null)
79
+ continue;
80
+ form.set(key, scalar(value));
81
+ }
82
+ if (typeof fields.file_base64 === 'string') {
83
+ const bytes = decodeBase64(fields.file_base64);
84
+ const type = typeof fields.content_type === 'string' ? fields.content_type : 'application/octet-stream';
85
+ const name = typeof fields.file_name === 'string' ? fields.file_name : 'file';
86
+ form.set('file', new Blob([bytes], { type }), name);
87
+ }
88
+ // fetch sets the multipart Content-Type (with its boundary) itself
89
+ return { body: form };
90
+ }
91
+ export class JinshujuApi {
92
+ options;
93
+ fetchImpl;
94
+ constructor(options) {
95
+ this.options = options;
96
+ // Don't store the global fetch as is: called as a method, its `this` is not the global object, and workerd / browsers throw Illegal invocation.
97
+ this.fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
98
+ }
99
+ get baseUrl() {
100
+ return this.options.baseUrl;
101
+ }
102
+ /** Sends the request; on 401, has the credential renew once and retries. */
103
+ async call(op, args, credential) {
104
+ const first = await this.send(op, args, await credential.token());
105
+ if (first.status !== 401)
106
+ return first;
107
+ if (!(await credential.invalidate()))
108
+ return first;
109
+ return this.send(op, args, await credential.token());
110
+ }
111
+ /** A raw request outside the operation table, for internal calls such as token verification. */
112
+ async raw(method, path, token) {
113
+ return this.perform(method, new URL(this.options.baseUrl.replace(/\/$/, '') + path), token, {});
114
+ }
115
+ async send(op, args, token) {
116
+ const url = buildUrl(this.options.baseUrl, op, args);
117
+ return this.perform(op.method.toUpperCase(), url, token, buildBody(op, args));
118
+ }
119
+ async perform(method, url, token, { body, contentType }) {
120
+ const headers = {
121
+ Authorization: `Bearer ${token}`,
122
+ Accept: 'application/json',
123
+ 'User-Agent': this.options.userAgent ?? 'jinshuju-mcp'
124
+ };
125
+ if (contentType)
126
+ headers['Content-Type'] = contentType;
127
+ const controller = new AbortController();
128
+ const timer = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 60_000);
129
+ let res;
130
+ try {
131
+ res = await this.fetchImpl(url, { method, headers, body, signal: controller.signal });
132
+ }
133
+ catch (error) {
134
+ const reason = controller.signal.aborted ? 'Request timed out' : 'Could not reach the Jinshuju API';
135
+ const detail = error instanceof Error ? `: ${error.message}` : '';
136
+ throw new TransportError(`${reason} (${method} ${url.pathname})${detail}`, { cause: error });
137
+ }
138
+ finally {
139
+ clearTimeout(timer);
140
+ }
141
+ const text = await res.text();
142
+ const out = { status: res.status };
143
+ const warnings = res.headers.get('x-api-input-warnings');
144
+ if (warnings)
145
+ out.warnings = warnings;
146
+ const retryAfter = res.headers.get('retry-after');
147
+ if (retryAfter)
148
+ out.retryAfter = retryAfter;
149
+ const rateLimit = {
150
+ limit: res.headers.get('x-ratelimit-limit') ?? undefined,
151
+ remaining: res.headers.get('x-ratelimit-remaining') ?? undefined,
152
+ reset: res.headers.get('x-ratelimit-reset') ?? undefined
153
+ };
154
+ if (rateLimit.limit || rateLimit.remaining || rateLimit.reset)
155
+ out.rateLimit = rateLimit;
156
+ if (!text)
157
+ return out;
158
+ try {
159
+ out.json = JSON.parse(text);
160
+ }
161
+ catch {
162
+ out.text = text.length > 2000 ? `${text.slice(0, 2000)}…` : text;
163
+ }
164
+ return out;
165
+ }
166
+ }
167
+ //# sourceMappingURL=api.js.map
package/dist/app.d.ts ADDED
@@ -0,0 +1,35 @@
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 { type OAuthMetadata } from '@modelcontextprotocol/server';
24
+ import { type McpAnalytics } from './analytics.js';
25
+ import type { Config } from './config.js';
26
+ export interface AppOptions {
27
+ config?: Partial<Config>;
28
+ fetch?: typeof fetch;
29
+ /** For tests: skips fetching the real authorization server metadata */
30
+ authServerMetadata?: OAuthMetadata;
31
+ /** Analytics. Omitted, config decides (on only with posthogApiKey); null forces it off; node.ts / tests pass their own instance */
32
+ analytics?: McpAnalytics | null;
33
+ }
34
+ export declare const MCP_PATH = "/mcp";
35
+ export declare function createApp(options?: AppOptions): Hono;