@jinshuju/mcp 0.0.0-stage → 0.2.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 +153 -2
- package/dist/analytics.d.ts +64 -0
- package/dist/analytics.js +139 -0
- package/dist/api.d.ts +54 -0
- package/dist/api.js +166 -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 +113 -0
- package/dist/generated/operations.json +3996 -0
- package/dist/generated/schemas.json +30825 -0
- package/dist/generated/tools.json +6130 -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 +8 -0
- package/dist/result.js +79 -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 +73 -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/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,154 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @jinshuju/mcp
|
|
2
2
|
|
|
3
|
-
|
|
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, with OAuth sign-in; for claude.ai, Cursor, Windsurf and the like;
|
|
9
|
+
- **Local (stdio)** — `npx -y @jinshuju/mcp`; for local clients such as Claude Code, sharing one sign-in with [@jinshuju/cli](https://github.com/jinshuju/cli).
|
|
10
|
+
|
|
11
|
+
## Remote use
|
|
12
|
+
|
|
13
|
+
Server URL: `https://mcp.jinshuju.net/mcp` (until launch, the URL published at open.jinshuju.net/mcp is authoritative).
|
|
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://mcp.jinshuju.net/mcp` |
|
|
19
|
+
| Cursor | `{ "mcpServers": { "jinshuju": { "url": "https://mcp.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
|
+
```bash
|
|
28
|
+
# Sign in once (browser OAuth); the credential is stored in ~/.jinshuju/config.json and shared by the cli and mcp
|
|
29
|
+
npx -y @jinshuju/cli auth login
|
|
30
|
+
|
|
31
|
+
# Claude Code
|
|
32
|
+
claude mcp add jinshuju -s user -- npx -y @jinshuju/mcp
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Or skip the sign-in and pass a token:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
claude mcp add jinshuju -s user -e JINSHUJU_ACCESS_TOKEN=your_token -- npx -y @jinshuju/mcp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
To check it works, ask the assistant to "list my Jinshuju forms".
|
|
42
|
+
|
|
43
|
+
## Tools
|
|
44
|
+
|
|
45
|
+
60 tools with the same names as those documented for the [Jinshuju MCP Server](https://open.jinshuju.net/mcp), plus `get_schema`. Each tool maps to one operation in openapi.yaml; a few combine two operations:
|
|
46
|
+
|
|
47
|
+
| Category | Tools |
|
|
48
|
+
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
49
|
+
| 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 |
|
|
50
|
+
| Exams / evaluations | create_exam_form, edit_exam_form, create_evaluation_form, edit_evaluation_form (create / edit the form + its scene settings, two requests) |
|
|
51
|
+
| 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 |
|
|
52
|
+
| What I submitted | list_my_submitted_forms, list_my_submitted_entries, search_my_submitted_entries |
|
|
53
|
+
| Comments | list_entry_comments, create_entry_comment, update_entry_comment, delete_entry_comment |
|
|
54
|
+
| Views | list_form_views, get_form_view, create_form_view, edit_form_view, delete_form_view, list_form_view_entries |
|
|
55
|
+
| Tables | list_tables, get_table, create_table, edit_table, move_table |
|
|
56
|
+
| Public queries | list_opensearch_queries, get_opensearch_query, create_opensearch_query, edit_opensearch_query, get_opensearch_field_suggestions |
|
|
57
|
+
| Folders / account | list_folders, create_folder, get_current_user, get_current_billing_account, list_account_users |
|
|
58
|
+
| Uploads | upload_entry_attachment, upload_form_image, upload_import_file |
|
|
59
|
+
| Schemas | get_schema (reads an openapi schema by name, e.g. FieldInput, TextFieldInput) |
|
|
60
|
+
|
|
61
|
+
Differences from the tools documented at open.jinshuju.net/mcp:
|
|
62
|
+
|
|
63
|
+
- Presigned uploads (`prepare_*_upload`) are not in openapi.yaml; the `upload_*` tools here upload directly (the file is passed as base64);
|
|
64
|
+
- `update_entry` only updates partially (PATCH); there is no full replace;
|
|
65
|
+
- Data structures such as field types are served by the `get_schema` tool and the `jinshuju://schemas/{name}` resource.
|
|
66
|
+
|
|
67
|
+
The full tool → operation mapping is in `mcp.overlay.yaml` and `src/generated/meta.json`.
|
|
68
|
+
|
|
69
|
+
**Size.** The full tools/list is about 39k tokens (o200k estimate; about 36k without outputSchema).
|
|
70
|
+
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.
|
|
71
|
+
The model reads them on demand with `get_schema("FieldInput")` / `get_schema("TextFieldInput")`.
|
|
72
|
+
|
|
73
|
+
## Environment variables
|
|
74
|
+
|
|
75
|
+
| Variable | Meaning |
|
|
76
|
+
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
77
|
+
| `JINSHUJU_ACCESS_TOKEN` | Credential for stdio mode; when set, the config file is not read |
|
|
78
|
+
| `JINSHUJU_CONFIG` | Config file path, default `~/.jinshuju/config.json` |
|
|
79
|
+
| `JINSHUJU_API_BASE_URL` | API URL, default `https://jinshuju.net/api/v1` |
|
|
80
|
+
| `JINSHUJU_AUTH_HOST` | Authorization server, default `https://account.jinshuju.net` |
|
|
81
|
+
| `JINSHUJU_MCP_PUBLIC_URL` | Public URL in HTTP mode (the resource in the OAuth metadata); a comma-separated list is chosen by the request's Host; empty infers it from the request |
|
|
82
|
+
| `JINSHUJU_MCP_MAX_RESULT_CHARS` | Maximum characters of one result, default 120000; longer results are truncated with a hint to page |
|
|
83
|
+
| `JINSHUJU_MCP_TOKEN_CACHE_TTL_MS` | Token verification cache in HTTP mode, default 5 minutes |
|
|
84
|
+
| `POSTHOG_API_KEY` | PostHog project token (`phc_…`); when set, HTTP mode reports MCP analytics, see below |
|
|
85
|
+
| `POSTHOG_HOST` | PostHog ingestion host, default `https://us.i.posthog.com` |
|
|
86
|
+
| `JINSHUJU_MCP_COLLECT_INTENT` | Whether the model writes the user's intent in each call (`context` argument → `$mcp_intent`), default `true` |
|
|
87
|
+
| `SENTRY_DSN` | Sentry project DSN; when set, the Workers entry reports exceptions, see below |
|
|
88
|
+
| `SENTRY_ENVIRONMENT` | Sentry environment name, optional |
|
|
89
|
+
| `PORT` / `HOST` | Where the Node entry listens, default 8787 / 0.0.0.0 |
|
|
90
|
+
|
|
91
|
+
## Usage analytics (PostHog MCP analytics)
|
|
92
|
+
|
|
93
|
+
With `POSTHOG_API_KEY` set, the HTTP deployment reports tool usage through [@posthog/mcp](https://posthog.com/docs/mcp-analytics), on the same terms as the built-in MCP at jinshuju.net/mcp:
|
|
94
|
+
|
|
95
|
+
- Only `$mcp_initialize` (legacy handshake) and `$mcp_tool_call` are reported; `tools/list`, resources, `$identify` and `$exception` are not;
|
|
96
|
+
- A tool call records the tool name, the argument names (`$mcp_input_keys`), the duration, whether it failed and the truncated error, **but not the argument values or the result**;
|
|
97
|
+
- Every tool gets one more argument, `context`, where the model writes what the user wants to do (`$mcp_intent`); email addresses, phone numbers, IPs and card numbers in it are masked before sending.
|
|
98
|
+
With `JINSHUJU_MCP_COLLECT_INTENT=false` the argument is no longer advertised, and a client that still sends it gets no error;
|
|
99
|
+
- `distinct_id` is the Jinshuju user id; `$session_id` is derived from a hash of the token (one session per token); the client name / version come from the handshake or each request's `_meta`.
|
|
100
|
+
|
|
101
|
+
stdio (local) mode reports nothing.
|
|
102
|
+
|
|
103
|
+
## Error monitoring (Sentry)
|
|
104
|
+
|
|
105
|
+
The Workers entry (`src/worker.ts`) is wrapped with [@sentry/cloudflare](https://docs.sentry.io/platforms/javascript/guides/cloudflare/) and reports once `SENTRY_DSN` is set:
|
|
106
|
+
|
|
107
|
+
- It reports the unexpected: exceptions thrown by tool handlers (an unreachable API / timeout is a warning), the API being unreachable during token verification, and errors the routes did not catch. A 4xx / 5xx from the API is a normal tool result and is not reported;
|
|
108
|
+
- No request bodies, IPs or cookies are collected, and tracing is off; sensitive request headers such as `Authorization` are scrubbed to `[Filtered]` by the SDK; the user is recorded only as the Jinshuju user id.
|
|
109
|
+
|
|
110
|
+
The Node entry (Docker) and stdio report nothing.
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
npm install
|
|
116
|
+
npm run generate # spec/openapi.yaml + mcp.overlay.yaml -> src/generated/ (operations / tools / schemas / meta)
|
|
117
|
+
npm run check # typecheck + oxlint + oxfmt
|
|
118
|
+
npm test # builds, then runs node:test without touching the network
|
|
119
|
+
npm run dev # local HTTP server: http://127.0.0.1:8787/mcp
|
|
120
|
+
npm run spec:update # fetches the latest live openapi.yaml and regenerates
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
After changing `mcp.overlay.yaml` or updating `spec/openapi.yaml`, run `npm run generate` and commit `src/generated/`; CI checks it.
|
|
124
|
+
|
|
125
|
+
## Deployment
|
|
126
|
+
|
|
127
|
+
- **Docker (official)**: `docker build -t jinshuju-mcp . && docker run -p 8787:8787 -e JINSHUJU_MCP_PUBLIC_URL=https://mcp.jinshuju.net jinshuju-mcp`
|
|
128
|
+
- **Cloudflare Workers (optional)**: `npx wrangler login && npm run worker:deploy`
|
|
129
|
+
- **WDL or another Workers-compatible platform (optional)**: with [`@wdl-dev/cli`](https://github.com/wdl-dev/cli) installed and its token configured, `npm run wdl:deploy`;
|
|
130
|
+
set the public URL as the secret `JINSHUJU_MCP_PUBLIC_URL` (`wdl secret put --worker jinshuju-mcp JINSHUJU_MCP_PUBLIC_URL`),
|
|
131
|
+
with several entry points comma-separated, e.g. `https://mcp.jinshuju.net,https://<ns>.<platform>/jinshuju-mcp`;
|
|
132
|
+
to report to PostHog add the secret `POSTHOG_API_KEY` (and `POSTHOG_HOST` if needed); to report to Sentry add `SENTRY_DSN`.
|
|
133
|
+
|
|
134
|
+
The service is stateless and needs no database, KV or Durable Objects.
|
|
135
|
+
|
|
136
|
+
## Evals
|
|
137
|
+
|
|
138
|
+
`evals/` is an automated, multi-model evaluation built from real usage: 42 tasks (categories 找表单 / 读数据 / 统计汇总 / 建表单 / 改设置 / 批量写入 / 导入 — find forms, read entries, aggregate, create forms, change settings, batch writes, import)
|
|
139
|
+
run against both this repository's MCP and the built-in MCP (jinshuju.net/mcp), are scored by assertions, produce pass-rate and token tables by model × MCP × category,
|
|
140
|
+
and write every run into a Jinshuju form. Usage, task format and metrics are in [evals/README.md](evals/README.md).
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
npm run eval -- seed # prepares fixture data in the eval account
|
|
144
|
+
npm run eval -- run # all tasks × EVAL_MODELS × new,builtin → evals/results/<run_id>/report.md
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Design notes
|
|
148
|
+
|
|
149
|
+
- [ARCHITECTURE.md](ARCHITECTURE.md) — modules, the path of one call, generation rules, authentication
|
|
150
|
+
- [evals/README.md](evals/README.md) — the eval system: sample set, runner, scoring, persistence
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
Apache-2.0
|
|
@@ -0,0 +1,64 @@
|
|
|
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 matches goldendata's built-in MCP (TrackMCPEventService):
|
|
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
|
+
* its category and the truncated error;
|
|
9
|
+
* - intent: every tool gets a context argument where the model says what the user wants to do ($mcp_intent). The SDK
|
|
10
|
+
* scrubs emails / phone numbers / IPs / card numbers from it before sending; JINSHUJU_MCP_COLLECT_INTENT=false turns
|
|
11
|
+
* it off entirely (the argument is no longer offered and nothing is reported);
|
|
12
|
+
* - distinct_id = the Jinshuju user id (from GET /me); $session_id is derived from the token hash: one session per token;
|
|
13
|
+
* - a session's initialize is reported at most once a day (deduplicated in process; best effort across Workers instances).
|
|
14
|
+
*
|
|
15
|
+
* Every other SDK injection stays off: no llm_model / conversation_id arguments, no get_more_tools / send_feedback tools.
|
|
16
|
+
*
|
|
17
|
+
* The PostHog client is created per request and shut down when the request ends, never shared across requests:
|
|
18
|
+
* posthog-node chains each flush onto the previous one, and in Workers a flush still pending when its request's context
|
|
19
|
+
* ends never settles. A shared client would hang every later request's reporting on that chain (the runtime reports
|
|
20
|
+
* "code had hung" and not a single event goes out).
|
|
21
|
+
*/
|
|
22
|
+
import type { AuthInfo, McpServer } from '@modelcontextprotocol/server';
|
|
23
|
+
import type { Config } from './config.js';
|
|
24
|
+
/** Asks the model the same question as goldendata's built-in MCP, so $mcp_intent is comparable across the two. */
|
|
25
|
+
export declare const CONTEXT_DESCRIPTION = "Why are you calling this tool? Briefly describe the user's goal.";
|
|
26
|
+
export declare const CONTEXT_INSTRUCTIONS: string;
|
|
27
|
+
export interface AnalyticsOptions {
|
|
28
|
+
/** PostHog project token (phc_…) */
|
|
29
|
+
apiKey: string;
|
|
30
|
+
/** PostHog ingest host */
|
|
31
|
+
host: string;
|
|
32
|
+
/** Whether to offer the context argument and report $mcp_intent; on by default */
|
|
33
|
+
collectIntent?: boolean;
|
|
34
|
+
/** For tests: the fetch used for reporting */
|
|
35
|
+
fetch?: typeof fetch;
|
|
36
|
+
now?: () => number;
|
|
37
|
+
/** Timeout of one request to PostHog (ms), 5 seconds by default; for tests */
|
|
38
|
+
requestTimeoutMs?: number;
|
|
39
|
+
}
|
|
40
|
+
/** The analytics handle of one request. */
|
|
41
|
+
export interface RequestAnalytics {
|
|
42
|
+
/** Sends the events this request buffered and closes the client. On Workers it goes into waitUntil; on Node there is no need to wait. */
|
|
43
|
+
flush(): Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
export declare class McpAnalytics {
|
|
46
|
+
readonly collectIntent: boolean;
|
|
47
|
+
private readonly options;
|
|
48
|
+
private readonly handshakes;
|
|
49
|
+
private readonly inflight;
|
|
50
|
+
private readonly now;
|
|
51
|
+
constructor(options: AnalyticsOptions);
|
|
52
|
+
/** Instruments this request's McpServer: the user, the session and the PostHog client are all closed over in this one request. */
|
|
53
|
+
instrument(server: McpServer, authInfo?: AuthInfo): Promise<RequestAnalytics>;
|
|
54
|
+
private track;
|
|
55
|
+
private beforeSend;
|
|
56
|
+
/** Clients handshake again and again on one connection, and every later handshake reports what the first one already did. */
|
|
57
|
+
private repeatedHandshake;
|
|
58
|
+
/** Waits until every request's reporting still in flight has been sent: used before the Node process exits, and in tests. */
|
|
59
|
+
flush(): Promise<void>;
|
|
60
|
+
/** Called before the process exits: same as flush. */
|
|
61
|
+
shutdown(): Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
/** Reports only when POSTHOG_API_KEY is set. */
|
|
64
|
+
export declare function analyticsFromConfig(cfg: Config, fetchImpl?: typeof fetch): McpAnalytics | undefined;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { deriveSessionIdFromMCPSession, instrument } from '@posthog/mcp';
|
|
2
|
+
import { PostHog } from 'posthog-node';
|
|
3
|
+
import { sha256, userIdOf } from './auth.js';
|
|
4
|
+
import { TOOLS } from './tools.js';
|
|
5
|
+
import { CONTEXT_ARGUMENT } from './validation.js';
|
|
6
|
+
/** Asks the model the same question as goldendata's built-in MCP, so $mcp_intent is comparable across the two. */
|
|
7
|
+
export const CONTEXT_DESCRIPTION = "Why are you calling this tool? Briefly describe the user's goal.";
|
|
8
|
+
export const CONTEXT_INSTRUCTIONS = `Every tool takes a \`${CONTEXT_ARGUMENT}\` argument: say what the user is actually trying to get done, ` +
|
|
9
|
+
'in their own words, rather than restating what the tool does — it is what tells us which needs these tools ' +
|
|
10
|
+
'are being asked to serve. Leave personal details (phone numbers, ID numbers, addresses) out of it.';
|
|
11
|
+
const KEPT_EVENTS = new Set(['$mcp_initialize', '$mcp_tool_call']);
|
|
12
|
+
/** By default the SDK attaches the (scrubbed) arguments and result; only argument names are recorded, so these go entirely. */
|
|
13
|
+
const DROPPED_PROPERTIES = ['$mcp_parameters', '$mcp_response', '$mcp_tool_description'];
|
|
14
|
+
const HANDSHAKE_TTL_MS = 24 * 60 * 60_000;
|
|
15
|
+
const HANDSHAKE_MAX_ENTRIES = 2000;
|
|
16
|
+
/** Time limit for sending the buffered events when a request ends: covers one request plus one retry */
|
|
17
|
+
const SHUTDOWN_TIMEOUT_MS = 15_000;
|
|
18
|
+
/**
|
|
19
|
+
* $mcp_input_keys records only argument names declared in openapi: the SDK cannot read the declarations fromJsonSchema
|
|
20
|
+
* produces, so it gets the full set here (every tool's argument names together, plus get_schema's name). Key names the
|
|
21
|
+
* model makes up are all recorded as [redacted].
|
|
22
|
+
*/
|
|
23
|
+
const DECLARED_PARAMETER_NAMES = new Set([
|
|
24
|
+
'name',
|
|
25
|
+
...TOOLS.flatMap((tool) => Object.keys(tool.inputSchema.properties ?? {}))
|
|
26
|
+
]);
|
|
27
|
+
/** Forwards the SDK's internal log to console only when something is wrong; drops the "Successfully set up…" every request prints. */
|
|
28
|
+
function warn(message) {
|
|
29
|
+
if (/^(Warning|Error|WARN)\b/.test(message))
|
|
30
|
+
console.warn(`[posthog-mcp] ${message}`);
|
|
31
|
+
}
|
|
32
|
+
export class McpAnalytics {
|
|
33
|
+
collectIntent;
|
|
34
|
+
options;
|
|
35
|
+
handshakes = new Map();
|
|
36
|
+
inflight = new Set();
|
|
37
|
+
now;
|
|
38
|
+
constructor(options) {
|
|
39
|
+
this.options = options;
|
|
40
|
+
this.collectIntent = options.collectIntent ?? true;
|
|
41
|
+
this.now = options.now ?? Date.now;
|
|
42
|
+
}
|
|
43
|
+
/** Instruments this request's McpServer: the user, the session and the PostHog client are all closed over in this one request. */
|
|
44
|
+
async instrument(server, authInfo) {
|
|
45
|
+
const sessionId = authInfo ? deriveSessionIdFromMCPSession(await sha256(authInfo.token)) : undefined;
|
|
46
|
+
const distinctId = userIdOf(authInfo);
|
|
47
|
+
const posthogOptions = {
|
|
48
|
+
host: this.options.host,
|
|
49
|
+
// 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.
|
|
50
|
+
disableGeoip: true,
|
|
51
|
+
// One batch per request; a failed send is retried at most once, so it does not outlast the waitUntil limit.
|
|
52
|
+
requestTimeout: this.options.requestTimeoutMs ?? 5_000,
|
|
53
|
+
fetchRetryCount: 1,
|
|
54
|
+
fetchRetryDelay: 1_000,
|
|
55
|
+
fetch: this.options.fetch
|
|
56
|
+
};
|
|
57
|
+
const posthog = new PostHog(this.options.apiKey, posthogOptions);
|
|
58
|
+
// posthog-node logs a failed send only in debug mode; record it and throw it to the caller on flush (console + Sentry).
|
|
59
|
+
const failures = [];
|
|
60
|
+
posthog.on('error', (error) => failures.push(error));
|
|
61
|
+
instrument(server, posthog, {
|
|
62
|
+
context: this.collectIntent ? { description: CONTEXT_DESCRIPTION } : false,
|
|
63
|
+
captureModel: false,
|
|
64
|
+
enableConversationId: false,
|
|
65
|
+
enableExceptionAutocapture: false,
|
|
66
|
+
identify: distinctId ? { distinctId } : null,
|
|
67
|
+
eventProperties: () => ({ mcp_caller_type: 'external' }),
|
|
68
|
+
shouldRecordInputKey: (key, { declared }) => declared || DECLARED_PARAMETER_NAMES.has(key),
|
|
69
|
+
beforeSend: this.beforeSend(sessionId),
|
|
70
|
+
logger: warn
|
|
71
|
+
});
|
|
72
|
+
return {
|
|
73
|
+
flush: () => this.track(posthog.shutdown(SHUTDOWN_TIMEOUT_MS).then(() => {
|
|
74
|
+
if (failures.length)
|
|
75
|
+
throw failures[0];
|
|
76
|
+
}))
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
track(flush) {
|
|
80
|
+
const tracked = flush.finally(() => this.inflight.delete(tracked));
|
|
81
|
+
this.inflight.add(tracked);
|
|
82
|
+
return tracked;
|
|
83
|
+
}
|
|
84
|
+
beforeSend(sessionId) {
|
|
85
|
+
return (event) => {
|
|
86
|
+
if (!KEPT_EVENTS.has(event.event))
|
|
87
|
+
return null;
|
|
88
|
+
const props = event.properties;
|
|
89
|
+
for (const key of DROPPED_PROPERTIES)
|
|
90
|
+
delete props[key];
|
|
91
|
+
if (sessionId) {
|
|
92
|
+
// 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.
|
|
93
|
+
if (event.distinct_id === props.$session_id)
|
|
94
|
+
event.distinct_id = sessionId;
|
|
95
|
+
props.$session_id = sessionId;
|
|
96
|
+
}
|
|
97
|
+
if (event.event === '$mcp_initialize' && this.repeatedHandshake(String(props.$session_id ?? '')))
|
|
98
|
+
return null;
|
|
99
|
+
return event;
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** Clients handshake again and again on one connection, and every later handshake reports what the first one already did. */
|
|
103
|
+
repeatedHandshake(sessionId) {
|
|
104
|
+
if (!sessionId)
|
|
105
|
+
return false;
|
|
106
|
+
const now = this.now();
|
|
107
|
+
const until = this.handshakes.get(sessionId);
|
|
108
|
+
if (until !== undefined && until > now)
|
|
109
|
+
return true;
|
|
110
|
+
this.handshakes.delete(sessionId);
|
|
111
|
+
if (this.handshakes.size >= HANDSHAKE_MAX_ENTRIES) {
|
|
112
|
+
const oldest = this.handshakes.keys().next().value;
|
|
113
|
+
if (oldest !== undefined)
|
|
114
|
+
this.handshakes.delete(oldest);
|
|
115
|
+
}
|
|
116
|
+
this.handshakes.set(sessionId, now + HANDSHAKE_TTL_MS);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
/** Waits until every request's reporting still in flight has been sent: used before the Node process exits, and in tests. */
|
|
120
|
+
async flush() {
|
|
121
|
+
await Promise.allSettled(this.inflight);
|
|
122
|
+
}
|
|
123
|
+
/** Called before the process exits: same as flush. */
|
|
124
|
+
shutdown() {
|
|
125
|
+
return this.flush();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** Reports only when POSTHOG_API_KEY is set. */
|
|
129
|
+
export function analyticsFromConfig(cfg, fetchImpl) {
|
|
130
|
+
if (!cfg.posthogApiKey)
|
|
131
|
+
return undefined;
|
|
132
|
+
return new McpAnalytics({
|
|
133
|
+
apiKey: cfg.posthogApiKey,
|
|
134
|
+
host: cfg.posthogHost,
|
|
135
|
+
collectIntent: cfg.collectIntent,
|
|
136
|
+
fetch: fetchImpl
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
//# 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,166 @@
|
|
|
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)) {
|
|
25
|
+
for (const item of value)
|
|
26
|
+
url.searchParams.append(`${p.name}[]`, scalar(item));
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
url.searchParams.set(p.name, scalar(value));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return url;
|
|
33
|
+
}
|
|
34
|
+
function scalar(value) {
|
|
35
|
+
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
36
|
+
}
|
|
37
|
+
function decodeBase64(data) {
|
|
38
|
+
const raw = data.startsWith('data:') && data.includes(',') ? data.slice(data.indexOf(',') + 1) : data;
|
|
39
|
+
const bin = atob(raw.replace(/\s+/g, ''));
|
|
40
|
+
const bytes = new Uint8Array(bin.length);
|
|
41
|
+
for (let i = 0; i < bin.length; i++)
|
|
42
|
+
bytes[i] = bin.charCodeAt(i);
|
|
43
|
+
return bytes;
|
|
44
|
+
}
|
|
45
|
+
/** The request body: when flattened, every argument key except the path / query parameters; otherwise the value under body.param. */
|
|
46
|
+
export function bodyPayload(op, args) {
|
|
47
|
+
if (!op.body)
|
|
48
|
+
return undefined;
|
|
49
|
+
let payload;
|
|
50
|
+
if (op.body.flatten) {
|
|
51
|
+
const rest = {};
|
|
52
|
+
for (const [key, value] of Object.entries(args)) {
|
|
53
|
+
if (!op.paramNames.includes(key) && value !== undefined)
|
|
54
|
+
rest[key] = value;
|
|
55
|
+
}
|
|
56
|
+
payload = rest;
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
payload = args[op.body.param ?? 'body'];
|
|
60
|
+
}
|
|
61
|
+
if (payload === undefined)
|
|
62
|
+
return undefined;
|
|
63
|
+
return op.body.wrap ? { [op.body.wrap]: payload } : payload;
|
|
64
|
+
}
|
|
65
|
+
export function buildBody(op, args) {
|
|
66
|
+
const payload = bodyPayload(op, args);
|
|
67
|
+
if (!op.body || payload === undefined)
|
|
68
|
+
return {};
|
|
69
|
+
if (op.body.contentType === 'application/json') {
|
|
70
|
+
return { body: JSON.stringify(payload), contentType: 'application/json' };
|
|
71
|
+
}
|
|
72
|
+
const fields = payload;
|
|
73
|
+
const form = new FormData();
|
|
74
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
75
|
+
if (key === 'file_base64' || key === 'file_name' || key === 'content_type')
|
|
76
|
+
continue;
|
|
77
|
+
if (value === undefined || value === null)
|
|
78
|
+
continue;
|
|
79
|
+
form.set(key, scalar(value));
|
|
80
|
+
}
|
|
81
|
+
if (typeof fields.file_base64 === 'string') {
|
|
82
|
+
const bytes = decodeBase64(fields.file_base64);
|
|
83
|
+
const type = typeof fields.content_type === 'string' ? fields.content_type : 'application/octet-stream';
|
|
84
|
+
const name = typeof fields.file_name === 'string' ? fields.file_name : 'file';
|
|
85
|
+
form.set('file', new Blob([bytes], { type }), name);
|
|
86
|
+
}
|
|
87
|
+
// fetch sets the multipart Content-Type (with its boundary) itself
|
|
88
|
+
return { body: form };
|
|
89
|
+
}
|
|
90
|
+
export class JinshujuApi {
|
|
91
|
+
options;
|
|
92
|
+
fetchImpl;
|
|
93
|
+
constructor(options) {
|
|
94
|
+
this.options = options;
|
|
95
|
+
// 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.
|
|
96
|
+
this.fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
|
|
97
|
+
}
|
|
98
|
+
get baseUrl() {
|
|
99
|
+
return this.options.baseUrl;
|
|
100
|
+
}
|
|
101
|
+
/** Sends the request; on 401, has the credential renew once and retries. */
|
|
102
|
+
async call(op, args, credential) {
|
|
103
|
+
const first = await this.send(op, args, await credential.token());
|
|
104
|
+
if (first.status !== 401)
|
|
105
|
+
return first;
|
|
106
|
+
if (!(await credential.invalidate()))
|
|
107
|
+
return first;
|
|
108
|
+
return this.send(op, args, await credential.token());
|
|
109
|
+
}
|
|
110
|
+
/** A raw request outside the operation table, for internal calls such as token verification. */
|
|
111
|
+
async raw(method, path, token) {
|
|
112
|
+
return this.perform(method, new URL(this.options.baseUrl.replace(/\/$/, '') + path), token, {});
|
|
113
|
+
}
|
|
114
|
+
async send(op, args, token) {
|
|
115
|
+
const url = buildUrl(this.options.baseUrl, op, args);
|
|
116
|
+
return this.perform(op.method.toUpperCase(), url, token, buildBody(op, args));
|
|
117
|
+
}
|
|
118
|
+
async perform(method, url, token, { body, contentType }) {
|
|
119
|
+
const headers = {
|
|
120
|
+
Authorization: `Bearer ${token}`,
|
|
121
|
+
Accept: 'application/json',
|
|
122
|
+
'User-Agent': this.options.userAgent ?? 'jinshuju-mcp'
|
|
123
|
+
};
|
|
124
|
+
if (contentType)
|
|
125
|
+
headers['Content-Type'] = contentType;
|
|
126
|
+
const controller = new AbortController();
|
|
127
|
+
const timer = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 60_000);
|
|
128
|
+
let res;
|
|
129
|
+
try {
|
|
130
|
+
res = await this.fetchImpl(url, { method, headers, body, signal: controller.signal });
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
const reason = controller.signal.aborted ? 'Request timed out' : 'Could not reach the Jinshuju API';
|
|
134
|
+
const detail = error instanceof Error ? `: ${error.message}` : '';
|
|
135
|
+
throw new TransportError(`${reason} (${method} ${url.pathname})${detail}`, { cause: error });
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
}
|
|
140
|
+
const text = await res.text();
|
|
141
|
+
const out = { status: res.status };
|
|
142
|
+
const warnings = res.headers.get('x-api-input-warnings');
|
|
143
|
+
if (warnings)
|
|
144
|
+
out.warnings = warnings;
|
|
145
|
+
const retryAfter = res.headers.get('retry-after');
|
|
146
|
+
if (retryAfter)
|
|
147
|
+
out.retryAfter = retryAfter;
|
|
148
|
+
const rateLimit = {
|
|
149
|
+
limit: res.headers.get('x-ratelimit-limit') ?? undefined,
|
|
150
|
+
remaining: res.headers.get('x-ratelimit-remaining') ?? undefined,
|
|
151
|
+
reset: res.headers.get('x-ratelimit-reset') ?? undefined
|
|
152
|
+
};
|
|
153
|
+
if (rateLimit.limit || rateLimit.remaining || rateLimit.reset)
|
|
154
|
+
out.rateLimit = rateLimit;
|
|
155
|
+
if (!text)
|
|
156
|
+
return out;
|
|
157
|
+
try {
|
|
158
|
+
out.json = JSON.parse(text);
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
out.text = text.length > 2000 ? `${text.slice(0, 2000)}…` : text;
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
//# 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;
|