@jinshuju/mcp 0.3.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
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.
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.
5
5
 
6
6
  Two ways to use it:
7
7
 
@@ -49,11 +49,11 @@ To check it works, ask the assistant to "list my Jinshuju forms".
49
49
 
50
50
  ## Tools
51
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:
52
+ Each tool maps to one operation in openapi.yaml; a few combine two operations:
53
53
 
54
54
  | Category | Tools |
55
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 |
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 |
57
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
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
59
  | What I submitted | list_my_submitted_forms, list_my_submitted_entries, search_my_submitted_entries |
@@ -62,11 +62,16 @@ To check it works, ask the assistant to "list my Jinshuju forms".
62
62
  | Tables | list_tables, get_table, create_table, edit_table, move_table |
63
63
  | Public queries | list_opensearch_queries, get_opensearch_query, create_opensearch_query, edit_opensearch_query, get_opensearch_field_suggestions |
64
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 |
65
+ | Uploads | prepare_entry_attachment_upload, prepare_form_image_upload, prepare_import_file_upload, upload_entry_attachment, upload_form_image, upload_import_file |
66
66
  | Schemas | get_schema (reads an openapi schema by name, e.g. FieldInput, TextFieldInput) |
67
67
 
68
68
  The full tool → operation mapping is in `mcp.overlay.yaml` and `src/generated/meta.json`.
69
69
 
70
+ **Uploads.** A file on disk goes through `prepare_*_upload`, which returns a one-time `upload_url` (valid 30 minutes);
71
+ the client posts the file to it itself (`curl -F file=@<path> <upload_url>`), so its bytes never pass through the model, then uses the returned `attachment_id`.
72
+ That needs a client with a shell that can reach jinshuju.net, such as Claude Code, Cursor or Codex.
73
+ `upload_*` carry the file as Base64 inside the tool call, which only suits small content the model has in hand.
74
+
70
75
  **Size.** The full tools/list is about 39k tokens (o200k estimate; about 36k without outputSchema).
71
76
  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
77
  The model reads them on demand with `get_schema("FieldInput")` / `get_schema("TextFieldInput")`.
@@ -12,7 +12,8 @@
12
12
  * - intent: every tool gets a context argument where the model says what the user wants to do ($mcp_intent). The SDK
13
13
  * scrubs emails / phone numbers / IPs / card numbers from it before sending; JINSHUJU_MCP_COLLECT_INTENT=false turns
14
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;
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;
16
17
  * - a session's initialize is reported at most once a day (deduplicated in process; best effort across Workers instances).
17
18
  *
18
19
  * Every other SDK injection stays off: no llm_model / conversation_id arguments, no get_more_tools / send_feedback tools.
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({ api, cacheTtlMs: cfg.tokenCacheTtlMs });
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 (the id from GET /me), undefined when no user was identified; analytics and Sentry both identify the user by it. */
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 } : undefined
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 (the id from GET /me), undefined when no user was identified; analytics and Sentry both identify the user by it. */
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);
@@ -2,17 +2,18 @@
2
2
  "generatedFrom": {
3
3
  "title": "Jinshuju API v1",
4
4
  "version": "1.0",
5
- "sha256": "288548a94e3932e7"
5
+ "sha256": "aecc3bef9088dcc7"
6
6
  },
7
7
  "baseUrl": "https://jinshuju.net/api/v1",
8
- "toolCount": 60,
9
- "operationCount": 128,
8
+ "toolCount": 64,
9
+ "operationCount": 176,
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",
@@ -59,6 +60,9 @@
59
60
  "get_current_user": "getCurrentUser",
60
61
  "get_current_billing_account": "getBillingAccount",
61
62
  "list_account_users": "listBillingAccountUsers",
63
+ "prepare_entry_attachment_upload": "prepareEntryAttachmentUpload",
64
+ "prepare_form_image_upload": "prepareFormImageUpload",
65
+ "prepare_import_file_upload": "prepareImportFileUpload",
62
66
  "upload_entry_attachment": "uploadEntryAttachment",
63
67
  "upload_form_image": "uploadFormImage",
64
68
  "upload_import_file": "uploadImportFile",
@@ -70,24 +74,26 @@
70
74
  "count_entries": "countEntries+countEntriesAcrossForms"
71
75
  },
72
76
  "pointerSchemas": [
73
- "BillingAccount",
74
- "EntryData",
75
- "EvaluationSetting",
76
- "EvaluationSettingInput",
77
- "FieldDefinition",
78
77
  "FieldInput",
79
78
  "FieldUpdateInput",
80
- "FormSetting",
81
- "FormSettingInput",
79
+ "FieldDefinition",
82
80
  "FormSettingWriteInput",
81
+ "FormSettingInput",
82
+ "FormSetting",
83
+ "ThemeInput",
83
84
  "FormTheme",
84
- "FormView",
85
+ "EvaluationSettingInput",
86
+ "EvaluationSetting",
85
87
  "FormViewInput",
86
- "GoodsItemInput",
87
- "OpensearchQuery",
88
+ "FormView",
88
89
  "OpensearchQueryInput",
89
- "ThemeInput",
90
- "TradeMerchant"
90
+ "OpensearchQuery",
91
+ "GoodsItemInput",
92
+ "EntryData",
93
+ "TradeMerchant",
94
+ "BillingAccount",
95
+ "ExamFieldInput",
96
+ "EvaluationFieldInput"
91
97
  ],
92
98
  "omitSchemas": [
93
99
  "FormCreateConstraints"
@@ -96,7 +102,7 @@
96
102
  "Forms": "Forms: list, structure, create and edit, plus field display rules, cooperators, copy, move and other related operations.",
97
103
  "Form Settings": "Form settings: submission settings, access policy, Webhook, theme, closing rules, exam / evaluation scene settings.",
98
104
  "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.",
99
- "Entries": "Form entries: list, query, create, update, delete and batch operations.",
105
+ "Entries": "Form entries: list, query, create, update, delete and batch operations, and sending SMS / email to entries.",
100
106
  "Tables": "Tables (kind=table): structure and entries; field types are limited to the column types tables support.",
101
107
  "Views": "Form / table views (view filters, sort, column configuration) and reading entries through a view.",
102
108
  "Comments": "Entry comments.",
@@ -106,9 +112,10 @@
106
112
  "Predefined Choices Sets": "Predefined choice sets.",
107
113
  "Templates": "Using templates.",
108
114
  "Contacts": "Contacts.",
109
- "Account": "Current user and account: members, e-invoices, payment history, SMS signature review status.",
115
+ "Account": "Current user and account: members, e-invoices, payment history, SMS signatures and templates, SMTP accounts.",
110
116
  "Trade": "小金商户: merchant and onboarding progress, payment orders, transaction statistics, refunds and payouts.",
111
- "Uploads": "Uploading attachments, form images and import files.",
112
- "Utilities": "Utility endpoints."
117
+ "Uploads": "Uploading attachments, form images, license images and import files.",
118
+ "Utilities": "Utility endpoints.",
119
+ "Routines": "Routines (`自动任务`): instructions Jiri carries out as you, on a schedule or when data changes, and their runs. The rules and messages are those of the routines page; times are in Beijing time (+08:00). OAuth tokens need the `routines` scope. As routines run unattended and spend AI points, self-registered clients cannot get it; only a registered application with `routines` among its scopes (such as the Jinshuju CLI) can ask for it."
113
120
  }
114
121
  }