@jinshuju/mcp 0.2.0 → 0.4.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/README.md +33 -99
- package/dist/analytics.d.ts +25 -4
- package/dist/analytics.js +55 -2
- package/dist/api.js +2 -1
- package/dist/app.js +6 -1
- package/dist/auth.d.ts +14 -1
- package/dist/auth.js +32 -2
- package/dist/generated/meta.json +5 -3
- package/dist/generated/operations.json +238 -48
- package/dist/generated/schemas.json +1871 -89
- package/dist/generated/tools.json +981 -120
- package/dist/result.d.ts +2 -0
- package/dist/result.js +3 -1
- package/dist/server.js +1 -1
- package/dist/tools.js +40 -15
- package/dist/types.d.ts +2 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +2 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -5,48 +5,55 @@ The tools are generated from the [open API v1 openapi.yaml](https://jinshuju.net
|
|
|
5
5
|
|
|
6
6
|
Two ways to use it:
|
|
7
7
|
|
|
8
|
-
- **Remote (HTTP)** — the official deployment
|
|
9
|
-
- **Local (stdio)** — `npx -y @jinshuju/mcp
|
|
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
10
|
|
|
11
11
|
## Remote use
|
|
12
12
|
|
|
13
|
-
Server URL: `https://
|
|
13
|
+
Server URL: `https://jinshuju.net/mcp`
|
|
14
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://
|
|
19
|
-
| Cursor | `{ "mcpServers": { "jinshuju": { "url": "https://
|
|
20
|
-
| Windsurf | Same as above, with the key `serverUrl`
|
|
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
21
|
|
|
22
22
|
Without OAuth (CI, scripts), send an access token directly: `--header "Authorization: Bearer YOUR_ACCESS_TOKEN"`, or add `headers` to the config.
|
|
23
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
24
|
|
|
25
25
|
## Local use (stdio)
|
|
26
26
|
|
|
27
|
-
|
|
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
|
|
27
|
+
Pass an access token (created as above) in `JINSHUJU_ACCESS_TOKEN`:
|
|
30
28
|
|
|
29
|
+
```bash
|
|
31
30
|
# Claude Code
|
|
32
|
-
claude mcp add jinshuju -s user -- npx -y @jinshuju/mcp
|
|
31
|
+
claude mcp add jinshuju -s user -e JINSHUJU_ACCESS_TOKEN=your_token -- npx -y @jinshuju/mcp
|
|
33
32
|
```
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
```
|
|
38
|
-
|
|
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
|
+
}
|
|
39
46
|
```
|
|
40
47
|
|
|
41
48
|
To check it works, ask the assistant to "list my Jinshuju forms".
|
|
42
49
|
|
|
43
50
|
## Tools
|
|
44
51
|
|
|
45
|
-
60 tools
|
|
52
|
+
The 60 tools documented for the [Jinshuju MCP Server](https://open.jinshuju.net/mcp), with the same names, plus delete_form. Each tool maps to one operation in openapi.yaml; a few combine two operations:
|
|
46
53
|
|
|
47
54
|
| Category | Tools |
|
|
48
55
|
| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
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
|
|
56
|
+
| Forms | list_forms, get_form, create_form, edit_form, copy_form, delete_form, move_form, list_form_cooperators, get_field_rules, edit_field_rules, check_field_data, preview_field_type_change, edit_theme |
|
|
50
57
|
| Exams / evaluations | create_exam_form, edit_exam_form, create_evaluation_form, edit_evaluation_form (create / edit the form + its scene settings, two requests) |
|
|
51
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 |
|
|
52
59
|
| What I submitted | list_my_submitted_forms, list_my_submitted_entries, search_my_submitted_entries |
|
|
@@ -58,96 +65,23 @@ To check it works, ask the assistant to "list my Jinshuju forms".
|
|
|
58
65
|
| Uploads | upload_entry_attachment, upload_form_image, upload_import_file |
|
|
59
66
|
| Schemas | get_schema (reads an openapi schema by name, e.g. FieldInput, TextFieldInput) |
|
|
60
67
|
|
|
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
68
|
The full tool → operation mapping is in `mcp.overlay.yaml` and `src/generated/meta.json`.
|
|
68
69
|
|
|
69
70
|
**Size.** The full tools/list is about 39k tokens (o200k estimate; about 36k without outputSchema).
|
|
70
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.
|
|
71
72
|
The model reads them on demand with `get_schema("FieldInput")` / `get_schema("TextFieldInput")`.
|
|
72
73
|
|
|
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:
|
|
74
|
+
## Environment variables (stdio)
|
|
106
75
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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 |
|
|
111
81
|
|
|
112
82
|
## Development
|
|
113
83
|
|
|
114
|
-
|
|
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
|
|
84
|
+
Building, running and deploying the server: [DEVELOPMENT.md](DEVELOPMENT.md). How it fits together: [ARCHITECTURE.md](ARCHITECTURE.md).
|
|
151
85
|
|
|
152
86
|
## License
|
|
153
87
|
|
package/dist/analytics.d.ts
CHANGED
|
@@ -2,14 +2,18 @@
|
|
|
2
2
|
* PostHog MCP analytics (@posthog/mcp): the HTTP deployment reports how the tools are used. stdio runs on the user's
|
|
3
3
|
* own machine and reports nothing.
|
|
4
4
|
*
|
|
5
|
-
* What is captured
|
|
5
|
+
* What is captured:
|
|
6
6
|
* - only $mcp_initialize and $mcp_tool_call; tools/list, resources/*, $identify and $exception are all dropped;
|
|
7
7
|
* - a tool call records only argument names ($mcp_input_keys), never argument values or the result; a failure records
|
|
8
|
-
* its
|
|
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);
|
|
9
12
|
* - intent: every tool gets a context argument where the model says what the user wants to do ($mcp_intent). The SDK
|
|
10
13
|
* scrubs emails / phone numbers / IPs / card numbers from it before sending; JINSHUJU_MCP_COLLECT_INTENT=false turns
|
|
11
14
|
* it off entirely (the argument is no longer offered and nothing is reported);
|
|
12
|
-
* - distinct_id = the Jinshuju user id (from GET /me
|
|
15
|
+
* - distinct_id = the Jinshuju user id (from GET /me, or the token info when the token lacks the users scope; see
|
|
16
|
+
* auth.ts); $session_id is derived from the token hash: one session per token;
|
|
13
17
|
* - a session's initialize is reported at most once a day (deduplicated in process; best effort across Workers instances).
|
|
14
18
|
*
|
|
15
19
|
* Every other SDK injection stays off: no llm_model / conversation_id arguments, no get_more_tools / send_feedback tools.
|
|
@@ -21,9 +25,26 @@
|
|
|
21
25
|
*/
|
|
22
26
|
import type { AuthInfo, McpServer } from '@modelcontextprotocol/server';
|
|
23
27
|
import type { Config } from './config.js';
|
|
24
|
-
/**
|
|
28
|
+
/** The question the model answers in the context argument; it becomes $mcp_intent. */
|
|
25
29
|
export declare const CONTEXT_DESCRIPTION = "Why are you calling this tool? Briefly describe the user's goal.";
|
|
26
30
|
export declare const CONTEXT_INSTRUCTIONS: string;
|
|
31
|
+
/** The buckets PostHog's own MCP analytics uses for $mcp_error_type, so its dashboards read our events directly. */
|
|
32
|
+
export type ErrorType = 'validation' | 'permission' | 'timeout' | 'rate_limited' | 'api_4xx' | 'api_5xx' | 'internal';
|
|
33
|
+
/**
|
|
34
|
+
* Buckets a failed tool call by its text, whose shapes are all ours: result.ts opens an API failure with `HTTP <status>:`
|
|
35
|
+
* and closes it with a hint, api.ts opens a TransportError with the reason, and the MCP SDK prefixes a validation failure.
|
|
36
|
+
* Anything else is an exception a tool threw, which is a bug of ours.
|
|
37
|
+
*/
|
|
38
|
+
export declare function classifyError(message: string): {
|
|
39
|
+
type: ErrorType;
|
|
40
|
+
status?: number;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Who is calling: `jiri` for the in-house assistant's remaining path through this server, which runs on the Python MCP
|
|
44
|
+
* SDK's defaults (client name mcp, python-httpx user agent) and nothing else does; `external` for everyone else. The
|
|
45
|
+
* retired built-in server reported the same two values.
|
|
46
|
+
*/
|
|
47
|
+
export declare function callerTypeOf(clientName: unknown, userAgent: unknown): 'jiri' | 'external';
|
|
27
48
|
export interface AnalyticsOptions {
|
|
28
49
|
/** PostHog project token (phc_…) */
|
|
29
50
|
apiKey: string;
|
package/dist/analytics.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { deriveSessionIdFromMCPSession, instrument } from '@posthog/mcp';
|
|
2
2
|
import { PostHog } from 'posthog-node';
|
|
3
3
|
import { sha256, userIdOf } from './auth.js';
|
|
4
|
+
import { FORBIDDEN_HINT } from './result.js';
|
|
4
5
|
import { TOOLS } from './tools.js';
|
|
5
6
|
import { CONTEXT_ARGUMENT } from './validation.js';
|
|
6
|
-
/**
|
|
7
|
+
/** The question the model answers in the context argument; it becomes $mcp_intent. */
|
|
7
8
|
export const CONTEXT_DESCRIPTION = "Why are you calling this tool? Briefly describe the user's goal.";
|
|
8
9
|
export const CONTEXT_INSTRUCTIONS = `Every tool takes a \`${CONTEXT_ARGUMENT}\` argument: say what the user is actually trying to get done, ` +
|
|
9
10
|
'in their own words, rather than restating what the tool does — it is what tells us which needs these tools ' +
|
|
@@ -24,6 +25,51 @@ const DECLARED_PARAMETER_NAMES = new Set([
|
|
|
24
25
|
'name',
|
|
25
26
|
...TOOLS.flatMap((tool) => Object.keys(tool.inputSchema.properties ?? {}))
|
|
26
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
|
+
}
|
|
27
73
|
/** Forwards the SDK's internal log to console only when something is wrong; drops the "Successfully set up…" every request prints. */
|
|
28
74
|
function warn(message) {
|
|
29
75
|
if (/^(Warning|Error|WARN)\b/.test(message))
|
|
@@ -64,7 +110,6 @@ export class McpAnalytics {
|
|
|
64
110
|
enableConversationId: false,
|
|
65
111
|
enableExceptionAutocapture: false,
|
|
66
112
|
identify: distinctId ? { distinctId } : null,
|
|
67
|
-
eventProperties: () => ({ mcp_caller_type: 'external' }),
|
|
68
113
|
shouldRecordInputKey: (key, { declared }) => declared || DECLARED_PARAMETER_NAMES.has(key),
|
|
69
114
|
beforeSend: this.beforeSend(sessionId),
|
|
70
115
|
logger: warn
|
|
@@ -96,6 +141,14 @@ export class McpAnalytics {
|
|
|
96
141
|
}
|
|
97
142
|
if (event.event === '$mcp_initialize' && this.repeatedHandshake(String(props.$session_id ?? '')))
|
|
98
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);
|
|
99
152
|
return event;
|
|
100
153
|
};
|
|
101
154
|
}
|
package/dist/api.js
CHANGED
|
@@ -21,11 +21,12 @@ export function buildUrl(baseUrl, op, args) {
|
|
|
21
21
|
const value = args[p.name];
|
|
22
22
|
if (value === undefined || value === null || value === '')
|
|
23
23
|
continue;
|
|
24
|
-
if (Array.isArray(value)) {
|
|
24
|
+
if (Array.isArray(value) && !p.json) {
|
|
25
25
|
for (const item of value)
|
|
26
26
|
url.searchParams.append(`${p.name}[]`, scalar(item));
|
|
27
27
|
}
|
|
28
28
|
else {
|
|
29
|
+
// A `content: application/json` parameter goes as one value: the array as JSON text, a string (JSON text already) as is
|
|
29
30
|
url.searchParams.set(p.name, scalar(value));
|
|
30
31
|
}
|
|
31
32
|
}
|
package/dist/app.js
CHANGED
|
@@ -35,7 +35,12 @@ export function createApp(options = {}) {
|
|
|
35
35
|
const cfg = { ...DEFAULTS, ...options.config };
|
|
36
36
|
const fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
|
|
37
37
|
const api = new JinshujuApi({ baseUrl: cfg.apiBaseUrl, fetch: fetchImpl, timeoutMs: cfg.timeoutMs });
|
|
38
|
-
const verifier = new TokenVerifier({
|
|
38
|
+
const verifier = new TokenVerifier({
|
|
39
|
+
api,
|
|
40
|
+
cacheTtlMs: cfg.tokenCacheTtlMs,
|
|
41
|
+
authServer: cfg.authServer,
|
|
42
|
+
fetch: fetchImpl
|
|
43
|
+
});
|
|
39
44
|
const analytics = options.analytics === undefined ? analyticsFromConfig(cfg, fetchImpl) : (options.analytics ?? undefined);
|
|
40
45
|
const instructions = analytics?.collectIntent ? `${INSTRUCTIONS}\n${CONTEXT_INSTRUCTIONS}` : INSTRUCTIONS;
|
|
41
46
|
let metadata;
|
package/dist/auth.d.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* - the request's Bearer (an OAuth token or a personal / account access token) goes to API v1 as is;
|
|
7
7
|
* - the API decides whether a token is valid: one light call (GET /me) checks it, and the result is cached by token
|
|
8
8
|
* hash for a while, so every tool call does not spend an extra API request;
|
|
9
|
+
* - GET /me also names the user. An OAuth token without the `users` scope gets 403 there, so the user id then comes from
|
|
10
|
+
* the authorization server's token info, which answers whatever the token's scopes;
|
|
9
11
|
* - with no token or an invalid one, answers 401 + WWW-Authenticate: Bearer resource_metadata=..., and the client
|
|
10
12
|
* goes through OAuth from there.
|
|
11
13
|
*/
|
|
@@ -14,6 +16,10 @@ import type { JinshujuApi } from './api.js';
|
|
|
14
16
|
export interface VerifierOptions {
|
|
15
17
|
api: JinshujuApi;
|
|
16
18
|
cacheTtlMs: number;
|
|
19
|
+
/** OAuth authorization server (issuer). When set, a valid token GET /me does not name is looked up at its token info. */
|
|
20
|
+
authServer?: string;
|
|
21
|
+
/** The fetch used for the token info lookup; the global fetch by default */
|
|
22
|
+
fetch?: typeof fetch;
|
|
17
23
|
maxEntries?: number;
|
|
18
24
|
now?: () => number;
|
|
19
25
|
}
|
|
@@ -23,12 +29,19 @@ export declare class TokenVerifier {
|
|
|
23
29
|
private readonly options;
|
|
24
30
|
private readonly cache;
|
|
25
31
|
private readonly now;
|
|
32
|
+
private readonly fetchImpl;
|
|
26
33
|
constructor(options: VerifierOptions);
|
|
27
34
|
/** Returns AuthInfo when valid, undefined when not. A network failure counts as invalid but is not cached. */
|
|
28
35
|
verify(token: string): Promise<AuthInfo | undefined>;
|
|
29
36
|
forget(token: string): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* The user behind an OAuth token, from the authorization server's token info (Doorkeeper's GET /oauth/token/info):
|
|
39
|
+
* it answers for any scope, and its resource_owner_id is the id GET /me returns. It gives no name. Personal and
|
|
40
|
+
* account tokens are not OAuth tokens and get 401 there. Best effort: on any failure the user stays unnamed.
|
|
41
|
+
*/
|
|
42
|
+
private tokenOwner;
|
|
30
43
|
}
|
|
31
|
-
/** The Jinshuju user id in AuthInfo (
|
|
44
|
+
/** The Jinshuju user id in AuthInfo (from GET /me or the token info), undefined when no user was identified; analytics and Sentry both identify the user by it. */
|
|
32
45
|
export declare function userIdOf(authInfo: AuthInfo | undefined): string | undefined;
|
|
33
46
|
export declare function bearerToken(request: Request): string | undefined;
|
|
34
47
|
export declare function challenge(resourceMetadataUrl: string, message: string): Response;
|
package/dist/auth.js
CHANGED
|
@@ -6,10 +6,14 @@
|
|
|
6
6
|
* - the request's Bearer (an OAuth token or a personal / account access token) goes to API v1 as is;
|
|
7
7
|
* - the API decides whether a token is valid: one light call (GET /me) checks it, and the result is cached by token
|
|
8
8
|
* hash for a while, so every tool call does not spend an extra API request;
|
|
9
|
+
* - GET /me also names the user. An OAuth token without the `users` scope gets 403 there, so the user id then comes from
|
|
10
|
+
* the authorization server's token info, which answers whatever the token's scopes;
|
|
9
11
|
* - with no token or an invalid one, answers 401 + WWW-Authenticate: Bearer resource_metadata=..., and the client
|
|
10
12
|
* goes through OAuth from there.
|
|
11
13
|
*/
|
|
12
14
|
import { bearerAuthChallengeResponse, OAuthError, OAuthErrorCode } from '@modelcontextprotocol/server';
|
|
15
|
+
/** Time limit of the token info lookup: it only names the user, so a slow authorization server must not hold up the request for long. */
|
|
16
|
+
const TOKEN_INFO_TIMEOUT_MS = 5_000;
|
|
13
17
|
/** Token hash: the key of the verification cache, and the source of the analytics session id. */
|
|
14
18
|
export async function sha256(text) {
|
|
15
19
|
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
|
|
@@ -19,9 +23,12 @@ export class TokenVerifier {
|
|
|
19
23
|
options;
|
|
20
24
|
cache = new Map();
|
|
21
25
|
now;
|
|
26
|
+
fetchImpl;
|
|
22
27
|
constructor(options) {
|
|
23
28
|
this.options = options;
|
|
24
29
|
this.now = options.now ?? Date.now;
|
|
30
|
+
// Don't store the global fetch as is: called as a method, its `this` is not the global object, and workerd throws Illegal invocation.
|
|
31
|
+
this.fetchImpl = options.fetch ?? ((input, init) => fetch(input, init));
|
|
25
32
|
}
|
|
26
33
|
/** Returns AuthInfo when valid, undefined when not. A network failure counts as invalid but is not cached. */
|
|
27
34
|
async verify(token) {
|
|
@@ -39,7 +46,7 @@ export class TokenVerifier {
|
|
|
39
46
|
token,
|
|
40
47
|
clientId: 'jinshuju',
|
|
41
48
|
scopes: [],
|
|
42
|
-
extra: me ? { userId: me.id, userName: me.name } :
|
|
49
|
+
extra: me ? { userId: me.id, userName: me.name } : await this.tokenOwner(token)
|
|
43
50
|
};
|
|
44
51
|
if (this.cache.size >= (this.options.maxEntries ?? 2000)) {
|
|
45
52
|
const oldest = this.cache.keys().next().value;
|
|
@@ -52,8 +59,31 @@ export class TokenVerifier {
|
|
|
52
59
|
async forget(token) {
|
|
53
60
|
this.cache.delete(await sha256(token));
|
|
54
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* The user behind an OAuth token, from the authorization server's token info (Doorkeeper's GET /oauth/token/info):
|
|
64
|
+
* it answers for any scope, and its resource_owner_id is the id GET /me returns. It gives no name. Personal and
|
|
65
|
+
* account tokens are not OAuth tokens and get 401 there. Best effort: on any failure the user stays unnamed.
|
|
66
|
+
*/
|
|
67
|
+
async tokenOwner(token) {
|
|
68
|
+
if (!this.options.authServer)
|
|
69
|
+
return undefined;
|
|
70
|
+
try {
|
|
71
|
+
const res = await this.fetchImpl(`${this.options.authServer.replace(/\/$/, '')}/oauth/token/info`, {
|
|
72
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
73
|
+
signal: AbortSignal.timeout(TOKEN_INFO_TIMEOUT_MS)
|
|
74
|
+
});
|
|
75
|
+
if (res.status !== 200)
|
|
76
|
+
return undefined;
|
|
77
|
+
const owner = (await res.json()).resource_owner_id;
|
|
78
|
+
return owner === undefined || owner === null || owner === '' ? undefined : { userId: owner };
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
console.warn('token info lookup failed:', error);
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
55
85
|
}
|
|
56
|
-
/** The Jinshuju user id in AuthInfo (
|
|
86
|
+
/** The Jinshuju user id in AuthInfo (from GET /me or the token info), undefined when no user was identified; analytics and Sentry both identify the user by it. */
|
|
57
87
|
export function userIdOf(authInfo) {
|
|
58
88
|
const id = authInfo?.extra?.userId;
|
|
59
89
|
return id === undefined || id === null || id === '' ? undefined : String(id);
|
package/dist/generated/meta.json
CHANGED
|
@@ -2,17 +2,18 @@
|
|
|
2
2
|
"generatedFrom": {
|
|
3
3
|
"title": "Jinshuju API v1",
|
|
4
4
|
"version": "1.0",
|
|
5
|
-
"sha256": "
|
|
5
|
+
"sha256": "ecaf2b639eaf7555"
|
|
6
6
|
},
|
|
7
7
|
"baseUrl": "https://jinshuju.net/api/v1",
|
|
8
|
-
"toolCount":
|
|
9
|
-
"operationCount":
|
|
8
|
+
"toolCount": 61,
|
|
9
|
+
"operationCount": 129,
|
|
10
10
|
"tools": {
|
|
11
11
|
"list_forms": "listForms",
|
|
12
12
|
"get_form": "getForm",
|
|
13
13
|
"create_form": "createForm",
|
|
14
14
|
"edit_form": "updateForm",
|
|
15
15
|
"copy_form": "copyForm",
|
|
16
|
+
"delete_form": "deleteForm",
|
|
16
17
|
"move_form": "moveFormToFolder",
|
|
17
18
|
"list_form_cooperators": "listFormCooperators",
|
|
18
19
|
"get_field_rules": "listFieldRules",
|
|
@@ -95,6 +96,7 @@
|
|
|
95
96
|
"tags": {
|
|
96
97
|
"Forms": "Forms: list, structure, create and edit, plus field display rules, cooperators, copy, move and other related operations.",
|
|
97
98
|
"Form Settings": "Form settings: submission settings, access policy, Webhook, theme, closing rules, exam / evaluation scene settings.",
|
|
99
|
+
"Plugins": "Plugin center plugins (check-in for now). A plugin campaign is bound to one form, and only its creator can see and change it. The account must have enabled the plugin in the plugin center, or the request returns 402.",
|
|
98
100
|
"Entries": "Form entries: list, query, create, update, delete and batch operations.",
|
|
99
101
|
"Tables": "Tables (kind=table): structure and entries; field types are limited to the column types tables support.",
|
|
100
102
|
"Views": "Form / table views (view filters, sort, column configuration) and reading entries through a view.",
|