@tiwater/office-mcp 0.21.28 → 0.21.30

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.
@@ -16,7 +16,7 @@ export function resultChannels(args) {
16
16
  return { returnContent, output };
17
17
  }
18
18
 
19
- export async function deliverLargeJsonResult({ tool, args, runtime, payload, sourcePaths, summary }) {
19
+ export async function deliverLargeJsonResult({ tool, args, runtime, payload, sourcePaths, summary, identity }) {
20
20
  const channels = resultChannels(args);
21
21
  const contentBytes = Buffer.byteLength(JSON.stringify(payload), 'utf8');
22
22
  const contentReturned = channels.returnContent && contentBytes <= returnedContentBudgetBytes;
@@ -38,6 +38,7 @@ export async function deliverLargeJsonResult({ tool, args, runtime, payload, sou
38
38
  contentWritten: channels.output !== null,
39
39
  },
40
40
  ...(summary === undefined ? {} : { summary }),
41
+ ...(identity === undefined ? {} : { identity }),
41
42
  ...(contentReturned ? { content: payload } : {}),
42
43
  };
43
44
  }
@@ -0,0 +1,154 @@
1
+ import process from 'node:process';
2
+
3
+ const JSONRPC_VERSION = '2.0';
4
+ const SUPPORTED_PROTOCOL_VERSIONS = ['2025-06-18', '2025-03-26', '2024-11-05', '2024-10-07'];
5
+ const FALLBACK_PROTOCOL_VERSION = '2025-06-18';
6
+
7
+ function writeMessage(message) {
8
+ process.stdout.write(`${JSON.stringify(message)}\n`);
9
+ }
10
+
11
+ function toError(code, message, data) {
12
+ return { code, message, ...(data === undefined ? {} : { data }) };
13
+ }
14
+
15
+ function normalizeToolCallError(error) {
16
+ if (!error) return toError(-32603, 'Unknown error');
17
+ if (Number.isInteger(error.code) && error.message) {
18
+ return toError(error.code, error.message, error.data);
19
+ }
20
+ return toError(-32603, error instanceof Error ? error.message : String(error));
21
+ }
22
+
23
+ export class McpStdioServer {
24
+ constructor({ name, version, instructions, tools, callTool, logger = console.error }) {
25
+ this.serverInfo = { name, version };
26
+ this.instructions = instructions;
27
+ this.tools = tools;
28
+ this.callTool = callTool;
29
+ this.logger = logger;
30
+ this.lineBuffer = '';
31
+ this.binaryBuffer = Buffer.alloc(0);
32
+ this.initialized = false;
33
+ }
34
+
35
+ start() {
36
+ process.stdin.on('data', chunk => this.#onData(chunk));
37
+ process.stdin.on('end', () => process.exit(0));
38
+ }
39
+
40
+ #onData(chunk) {
41
+ const text = chunk.toString('utf8');
42
+
43
+ if (this.binaryBuffer.length > 0 || text.includes('Content-Length:')) {
44
+ this.binaryBuffer = Buffer.concat([this.binaryBuffer, chunk]);
45
+ this.#drainContentLengthBuffer();
46
+ return;
47
+ }
48
+
49
+ this.lineBuffer += text;
50
+ while (true) {
51
+ const newlineIndex = this.lineBuffer.indexOf('\n');
52
+ if (newlineIndex === -1) return;
53
+ const line = this.lineBuffer.slice(0, newlineIndex).replace(/\r$/, '').trim();
54
+ this.lineBuffer = this.lineBuffer.slice(newlineIndex + 1);
55
+ if (!line) continue;
56
+ this.#parseAndHandle(line, null);
57
+ }
58
+ }
59
+
60
+ #drainContentLengthBuffer() {
61
+ while (true) {
62
+ const headerEnd = this.binaryBuffer.indexOf('\r\n\r\n');
63
+ if (headerEnd === -1) return;
64
+
65
+ const headerText = this.binaryBuffer.subarray(0, headerEnd).toString('utf8');
66
+ const lengthMatch = headerText.match(/Content-Length:\s*(\d+)/i);
67
+ if (!lengthMatch) {
68
+ this.logger('Missing Content-Length header');
69
+ this.binaryBuffer = Buffer.alloc(0);
70
+ return;
71
+ }
72
+
73
+ const contentLength = Number(lengthMatch[1]);
74
+ const messageStart = headerEnd + 4;
75
+ const messageEnd = messageStart + contentLength;
76
+ if (this.binaryBuffer.length < messageEnd) return;
77
+
78
+ const body = this.binaryBuffer.subarray(messageStart, messageEnd).toString('utf8');
79
+ this.binaryBuffer = this.binaryBuffer.subarray(messageEnd);
80
+ this.#parseAndHandle(body, null);
81
+ }
82
+ }
83
+
84
+ #parseAndHandle(body, idHint) {
85
+ let message;
86
+ try {
87
+ message = JSON.parse(body);
88
+ } catch (error) {
89
+ writeMessage({ jsonrpc: JSONRPC_VERSION, id: idHint, error: toError(-32700, 'Parse error', String(error)) });
90
+ return;
91
+ }
92
+
93
+ void this.#handleMessage(message);
94
+ }
95
+
96
+ async #handleMessage(message) {
97
+ if (!message || message.jsonrpc !== JSONRPC_VERSION || typeof message.method !== 'string') {
98
+ if ('id' in (message || {})) {
99
+ writeMessage({ jsonrpc: JSONRPC_VERSION, id: message.id ?? null, error: toError(-32600, 'Invalid Request') });
100
+ }
101
+ return;
102
+ }
103
+
104
+ const { id, method, params = {} } = message;
105
+ const isNotification = id === undefined;
106
+
107
+ try {
108
+ switch (method) {
109
+ case 'initialize': {
110
+ const requested = params.protocolVersion;
111
+ const protocolVersion = SUPPORTED_PROTOCOL_VERSIONS.includes(requested) ? requested : FALLBACK_PROTOCOL_VERSION;
112
+ const result = {
113
+ protocolVersion,
114
+ capabilities: { tools: {} },
115
+ serverInfo: this.serverInfo,
116
+ ...(this.instructions ? { instructions: this.instructions } : {}),
117
+ };
118
+ if (!isNotification) writeMessage({ jsonrpc: JSONRPC_VERSION, id, result });
119
+ return;
120
+ }
121
+ case 'notifications/initialized': {
122
+ this.initialized = true;
123
+ return;
124
+ }
125
+ case 'ping': {
126
+ if (!isNotification) writeMessage({ jsonrpc: JSONRPC_VERSION, id, result: {} });
127
+ return;
128
+ }
129
+ case 'tools/list': {
130
+ if (!isNotification) writeMessage({ jsonrpc: JSONRPC_VERSION, id, result: { tools: this.tools } });
131
+ return;
132
+ }
133
+ case 'tools/call': {
134
+ const name = params?.name;
135
+ const args = params?.arguments ?? {};
136
+ if (typeof name !== 'string' || !name) {
137
+ if (!isNotification) writeMessage({ jsonrpc: JSONRPC_VERSION, id, error: toError(-32602, 'Invalid params: missing tool name') });
138
+ return;
139
+ }
140
+ const result = await this.callTool(name, args);
141
+ if (!isNotification) writeMessage({ jsonrpc: JSONRPC_VERSION, id, result });
142
+ return;
143
+ }
144
+ default: {
145
+ if (!isNotification) writeMessage({ jsonrpc: JSONRPC_VERSION, id, error: toError(-32601, `Method not found: ${method}`) });
146
+ }
147
+ }
148
+ } catch (error) {
149
+ if (!isNotification) {
150
+ writeMessage({ jsonrpc: JSONRPC_VERSION, id, error: normalizeToolCallError(error) });
151
+ }
152
+ }
153
+ }
154
+ }
@@ -2,7 +2,7 @@
2
2
  "schema": "tiwater.office-provider-contract-manifest/v1",
3
3
  "provider": {
4
4
  "id": "@tiwater/office-mcp",
5
- "version": "0.21.28"
5
+ "version": "0.21.30"
6
6
  },
7
7
  "tools": [
8
8
  {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tiwater/office-mcp",
3
- "version": "0.21.28",
4
- "description": "Published MCP server for Tiwater Office document capabilities",
3
+ "version": "0.21.30",
4
+ "description": "Published MCP distribution for independent Tiwater Office, PDF, and Text document capabilities",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -10,7 +10,9 @@
10
10
  "directory": "servers"
11
11
  },
12
12
  "bin": {
13
- "tiwater-office-mcp": "office/index.mjs"
13
+ "tiwater-office-mcp": "office/index.mjs",
14
+ "tiwater-pdf-mcp": "pdf/index.mjs",
15
+ "tiwater-text-mcp": "text/index.mjs"
14
16
  },
15
17
  "engines": {
16
18
  "node": ">=20"
@@ -19,11 +21,20 @@
19
21
  "_shared/tool-runtime.mjs",
20
22
  "_shared/large-json-result.mjs",
21
23
  "_shared/output-write-lock.mjs",
24
+ "_shared/mcp-stdio.mjs",
22
25
  "office/index.mjs",
23
26
  "office/docx-object-identity.mjs",
24
27
  "office/README.md",
25
28
  "office/contracts/tiwater-office-provider-contract-manifest-v1.json",
26
- "office/contracts/*.schema.json"
29
+ "office/contracts/*.schema.json",
30
+ "pdf/index.mjs",
31
+ "pdf/README.md",
32
+ "pdf/contracts/*.json",
33
+ "text/index.mjs",
34
+ "text/observation.mjs",
35
+ "text/README.md",
36
+ "text/contracts/tiwater-text-provider-contract-manifest-v1.json",
37
+ "text/contracts/*.schema.json"
27
38
  ],
28
39
  "publishConfig": {
29
40
  "access": "public"
package/pdf/README.md ADDED
@@ -0,0 +1,30 @@
1
+ # Tiwater PDF MCP
2
+
3
+ Published, Agent-facing stdio MCP server for PDF inspection, table extraction,
4
+ and OCR. It is a separate executable, process, and tool surface from the Office
5
+ MCP, distributed in the same npm package so both use one trusted release path.
6
+
7
+ ## Tools
8
+
9
+ - `pdf_inspect`
10
+ - `pdf_extract_tables`
11
+ - `pdf_find_table`
12
+ - `pdf_extract_table_details`
13
+ - `pdf_ocr` (pinned Aliyun `qwen3.8-max`; per-invocation Supen credential)
14
+
15
+ Every tool is read-only and records the exact input PDF identity. Read results
16
+ can be retained in immutable JSON artifacts. `pdf_inspect` always requires that
17
+ durable artifact and separately returns a bounded document identity without
18
+ traversing page content.
19
+
20
+ ## Install and run
21
+
22
+ ```bash
23
+ npm install --global @tiwater/office-mcp
24
+ tiwater-pdf-mcp
25
+ ```
26
+
27
+ The server invokes the independently published `tiwater-pdf` executable from
28
+ `PATH`. It does not fall back to a repository checkout. OCR credentials are
29
+ provided to that child for one invocation by the runtime environment; the MCP
30
+ does not read or persist provider credentials.
@@ -0,0 +1,37 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "input": {
6
+ "type": "string",
7
+ "minLength": 1,
8
+ "description": "Path to the current PDF revision.",
9
+ "x-tiwater-file-role": "read"
10
+ },
11
+ "returnContent": {
12
+ "type": "boolean",
13
+ "description": "Return the complete result directly when it fits the published response limit."
14
+ },
15
+ "output": {
16
+ "type": "string",
17
+ "minLength": 1,
18
+ "description": "Absolute path for the immutable JSON observation artifact. An identical replay may reuse identical bytes; different content is never written over it.",
19
+ "x-tiwater-file-role": "write",
20
+ "x-tiwater-file-effect": false
21
+ },
22
+ "pages": {
23
+ "type": "array",
24
+ "minItems": 1,
25
+ "uniqueItems": true,
26
+ "items": {
27
+ "type": "integer",
28
+ "minimum": 1
29
+ }
30
+ }
31
+ },
32
+ "required": [
33
+ "input",
34
+ "returnContent"
35
+ ],
36
+ "additionalProperties": false
37
+ }
@@ -0,0 +1,40 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "input": {
6
+ "type": "string",
7
+ "minLength": 1,
8
+ "description": "Path to the current PDF revision.",
9
+ "x-tiwater-file-role": "read"
10
+ },
11
+ "returnContent": {
12
+ "type": "boolean",
13
+ "description": "Return the complete result directly when it fits the published response limit."
14
+ },
15
+ "output": {
16
+ "type": "string",
17
+ "minLength": 1,
18
+ "description": "Absolute path for the immutable JSON observation artifact. An identical replay may reuse identical bytes; different content is never written over it.",
19
+ "x-tiwater-file-role": "write",
20
+ "x-tiwater-file-effect": false
21
+ },
22
+ "pages": {
23
+ "type": "array",
24
+ "minItems": 1,
25
+ "uniqueItems": true,
26
+ "items": {
27
+ "type": "integer",
28
+ "minimum": 1
29
+ }
30
+ },
31
+ "autoSpan": {
32
+ "type": "boolean"
33
+ }
34
+ },
35
+ "required": [
36
+ "input",
37
+ "returnContent"
38
+ ],
39
+ "additionalProperties": false
40
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "input": {
6
+ "type": "string",
7
+ "minLength": 1,
8
+ "description": "Path to the current PDF revision.",
9
+ "x-tiwater-file-role": "read"
10
+ },
11
+ "returnContent": {
12
+ "type": "boolean",
13
+ "description": "Return the complete result directly when it fits the published response limit."
14
+ },
15
+ "output": {
16
+ "type": "string",
17
+ "minLength": 1,
18
+ "description": "Absolute path for the immutable JSON observation artifact. An identical replay may reuse identical bytes; different content is never written over it.",
19
+ "x-tiwater-file-role": "write",
20
+ "x-tiwater-file-effect": false
21
+ },
22
+ "name": {
23
+ "type": "string",
24
+ "minLength": 1
25
+ },
26
+ "autoSpan": {
27
+ "type": "boolean"
28
+ }
29
+ },
30
+ "required": [
31
+ "input",
32
+ "name",
33
+ "returnContent"
34
+ ],
35
+ "additionalProperties": false
36
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "input": {
6
+ "type": "string",
7
+ "minLength": 1,
8
+ "description": "Path to the current PDF revision.",
9
+ "x-tiwater-file-role": "read"
10
+ },
11
+ "returnContent": {
12
+ "type": "boolean",
13
+ "description": "Return the complete result directly when it fits the published response limit."
14
+ },
15
+ "output": {
16
+ "type": "string",
17
+ "minLength": 1,
18
+ "description": "Absolute path for the immutable JSON observation artifact. An identical replay may reuse identical bytes; different content is never written over it.",
19
+ "x-tiwater-file-role": "write",
20
+ "x-tiwater-file-effect": false
21
+ }
22
+ },
23
+ "required": [
24
+ "input",
25
+ "output"
26
+ ],
27
+ "additionalProperties": false
28
+ }
@@ -0,0 +1,37 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "input": {
6
+ "type": "string",
7
+ "minLength": 1,
8
+ "description": "Path to the current PDF revision.",
9
+ "x-tiwater-file-role": "read"
10
+ },
11
+ "returnContent": {
12
+ "type": "boolean",
13
+ "description": "Return the complete result directly when it fits the published response limit."
14
+ },
15
+ "output": {
16
+ "type": "string",
17
+ "minLength": 1,
18
+ "description": "Absolute path for the immutable JSON observation artifact. An identical replay may reuse identical bytes; different content is never written over it.",
19
+ "x-tiwater-file-role": "write",
20
+ "x-tiwater-file-effect": false
21
+ },
22
+ "pages": {
23
+ "type": "array",
24
+ "minItems": 1,
25
+ "uniqueItems": true,
26
+ "items": {
27
+ "type": "integer",
28
+ "minimum": 1
29
+ }
30
+ }
31
+ },
32
+ "required": [
33
+ "input",
34
+ "returnContent"
35
+ ],
36
+ "additionalProperties": false
37
+ }
@@ -0,0 +1,47 @@
1
+ {
2
+ "schema": "tiwater.pdf-provider-contract-manifest/v1",
3
+ "provider": {
4
+ "id": "@tiwater/office-mcp",
5
+ "version": "0.21.30"
6
+ },
7
+ "runtime": {
8
+ "command": "tiwater-pdf"
9
+ },
10
+ "tools": [
11
+ {
12
+ "name": "pdf_inspect",
13
+ "inputContract": {
14
+ "path": "contracts/pdf_inspect.schema.json",
15
+ "sha256": "0b397cca1351bfe79af8bb2b81e107c9d566f7772f3c6f7eea54d26048116eeb"
16
+ }
17
+ },
18
+ {
19
+ "name": "pdf_extract_tables",
20
+ "inputContract": {
21
+ "path": "contracts/pdf_extract_tables.schema.json",
22
+ "sha256": "2ff2d609964e0d2c6cc84c8f8caf6002de3ce8ce4b78e28a99a9e0430502d3b4"
23
+ }
24
+ },
25
+ {
26
+ "name": "pdf_find_table",
27
+ "inputContract": {
28
+ "path": "contracts/pdf_find_table.schema.json",
29
+ "sha256": "1bb441cedaeb78a3776096718d1ce51f29e7832e19ed0a43f9138c45a400bd18"
30
+ }
31
+ },
32
+ {
33
+ "name": "pdf_ocr",
34
+ "inputContract": {
35
+ "path": "contracts/pdf_ocr.schema.json",
36
+ "sha256": "8910ece3646ca0154ab8d18bd602d2fc9666067543d4cdc2b999a8d8543aada2"
37
+ }
38
+ },
39
+ {
40
+ "name": "pdf_extract_table_details",
41
+ "inputContract": {
42
+ "path": "contracts/pdf_extract_table_details.schema.json",
43
+ "sha256": "8910ece3646ca0154ab8d18bd602d2fc9666067543d4cdc2b999a8d8543aada2"
44
+ }
45
+ }
46
+ ]
47
+ }
package/pdf/index.mjs ADDED
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash } from 'node:crypto';
4
+ import { readFileSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ import { deliverLargeJsonResult } from '../_shared/large-json-result.mjs';
9
+ import { McpStdioServer } from '../_shared/mcp-stdio.mjs';
10
+ import {
11
+ commandCandidate,
12
+ createToolResult,
13
+ requireString,
14
+ runJsonCandidateChain,
15
+ } from '../_shared/tool-runtime.mjs';
16
+
17
+ const pdfRoot = path.dirname(fileURLToPath(import.meta.url));
18
+ const distributionRoot = path.resolve(pdfRoot, '..');
19
+ const packageJson = readJson(path.join(distributionRoot, 'package.json'));
20
+ const manifest = readJson(path.join(
21
+ pdfRoot, 'contracts', 'tiwater-pdf-provider-contract-manifest-v1.json',
22
+ ));
23
+ if (manifest.schema !== 'tiwater.pdf-provider-contract-manifest/v1'
24
+ || manifest.provider?.id !== packageJson.name
25
+ || manifest.provider?.version !== packageJson.version) {
26
+ throw new Error('pdf-provider-contract-manifest-invalid');
27
+ }
28
+
29
+ const pdfCandidates = [commandCandidate('tiwater-pdf')];
30
+ const artifactSchema = {
31
+ type: 'object',
32
+ properties: {
33
+ path: { type: 'string' },
34
+ sha256: { type: 'string', pattern: '^[0-9a-f]{64}$' },
35
+ bytes: { type: 'integer', minimum: 1 },
36
+ },
37
+ required: ['path', 'sha256', 'bytes'],
38
+ additionalProperties: false,
39
+ };
40
+ const receiptSchema = {
41
+ type: 'object',
42
+ properties: {
43
+ contentBytes: { type: 'integer', minimum: 0 },
44
+ contentReturned: { type: 'boolean' },
45
+ contentWritten: { type: 'boolean' },
46
+ },
47
+ required: ['contentBytes', 'contentReturned', 'contentWritten'],
48
+ additionalProperties: false,
49
+ };
50
+ const largeResultOutputSchema = {
51
+ type: 'object',
52
+ properties: {
53
+ tool: { type: 'string' },
54
+ runtime: {
55
+ type: 'object',
56
+ properties: {
57
+ command: { type: 'string' },
58
+ arguments: { type: 'array', items: { type: 'string' } },
59
+ },
60
+ required: ['command', 'arguments'],
61
+ additionalProperties: false,
62
+ },
63
+ sources: { type: 'array', minItems: 1, maxItems: 1, items: artifactSchema },
64
+ returnContent: { type: 'boolean' },
65
+ artifact: { anyOf: [artifactSchema, { type: 'null' }] },
66
+ receipt: receiptSchema,
67
+ content: {},
68
+ },
69
+ required: ['tool', 'runtime', 'sources', 'returnContent', 'artifact', 'receipt'],
70
+ additionalProperties: false,
71
+ };
72
+ const pageIdentitySchema = {
73
+ type: 'object',
74
+ properties: {
75
+ page: { type: 'integer', minimum: 1 },
76
+ width: { type: 'number', exclusiveMinimum: 0 },
77
+ height: { type: 'number', exclusiveMinimum: 0 },
78
+ imageCount: { type: 'integer', minimum: 0 },
79
+ wordCount: { type: 'integer', minimum: 0 },
80
+ imageOnly: { type: 'boolean' },
81
+ },
82
+ required: ['page', 'width', 'height', 'imageCount', 'wordCount', 'imageOnly'],
83
+ additionalProperties: false,
84
+ };
85
+ const inspectIdentitySchema = {
86
+ type: 'object',
87
+ properties: {
88
+ format: { const: 'pdf' },
89
+ pageCount: { type: 'integer', minimum: 1 },
90
+ title: { type: ['string', 'null'] },
91
+ author: { type: ['string', 'null'] },
92
+ subject: { type: ['string', 'null'] },
93
+ imageCount: { type: 'integer', minimum: 0 },
94
+ wordCount: { type: 'integer', minimum: 0 },
95
+ scannedPageCount: { type: 'integer', minimum: 0 },
96
+ imageOnly: { type: 'boolean' },
97
+ openingPages: { type: 'array', maxItems: 3, items: pageIdentitySchema },
98
+ },
99
+ required: [
100
+ 'format', 'pageCount', 'title', 'author', 'subject', 'imageCount', 'wordCount',
101
+ 'scannedPageCount', 'imageOnly', 'openingPages',
102
+ ],
103
+ additionalProperties: false,
104
+ };
105
+ const inspectOutputSchema = structuredClone(largeResultOutputSchema);
106
+ inspectOutputSchema.properties.identity = inspectIdentitySchema;
107
+ inspectOutputSchema.required.push('identity');
108
+
109
+ const definitions = new Map([
110
+ ['pdf_inspect', {
111
+ description: 'Inspect one current PDF revision. Always retain the complete observation at output and return a bounded identity containing page and document metadata without traversing document content.',
112
+ outputSchema: inspectOutputSchema,
113
+ }],
114
+ ['pdf_extract_tables', {
115
+ description: 'Extract tables from selected current PDF pages with deterministic published extraction. Set returnContent true to return complete bounded content; provide output to retain the complete immutable result. At least one result channel is required.',
116
+ outputSchema: largeResultOutputSchema,
117
+ }],
118
+ ['pdf_find_table', {
119
+ description: 'Find a caller-named table in one current PDF without deciding its business role. Set returnContent true to return complete bounded content; provide output to retain the complete immutable result. At least one result channel is required.',
120
+ outputSchema: largeResultOutputSchema,
121
+ }],
122
+ ['pdf_ocr', {
123
+ description: 'Observe selected current PDF pages through the fixed Aliyun qwen3.8-max OCR model and per-invocation Supen credential. Set returnContent true to return complete bounded content; provide output to retain the complete immutable result. At least one result channel is required.',
124
+ outputSchema: largeResultOutputSchema,
125
+ }],
126
+ ['pdf_extract_table_details', {
127
+ description: 'Read visual table cells, text spans, fonts, colors, and line evidence from selected current PDF pages. Set returnContent true to return complete bounded content; provide output to retain the complete immutable result. At least one result channel is required.',
128
+ outputSchema: largeResultOutputSchema,
129
+ }],
130
+ ]);
131
+
132
+ const tools = manifest.tools.map((entry) => {
133
+ const definition = definitions.get(entry.name);
134
+ if (!definition) throw new Error(`pdf-provider-tool-definition-missing:${entry.name}`);
135
+ const contractPath = path.join(pdfRoot, entry.inputContract.path);
136
+ const bytes = readFileSync(contractPath);
137
+ if (createHash('sha256').update(bytes).digest('hex') !== entry.inputContract.sha256) {
138
+ throw new Error(`pdf-provider-input-contract-hash-invalid:${entry.name}`);
139
+ }
140
+ return {
141
+ name: entry.name,
142
+ description: definition.description,
143
+ inputSchema: JSON.parse(bytes.toString('utf8')),
144
+ outputSchema: definition.outputSchema,
145
+ annotations: {
146
+ readOnlyHint: true,
147
+ idempotentHint: true,
148
+ destructiveHint: false,
149
+ openWorldHint: false,
150
+ },
151
+ };
152
+ });
153
+
154
+ async function callTool(name, args) {
155
+ switch (name) {
156
+ case 'pdf_inspect': return createToolResult(await pdfInspect(args));
157
+ case 'pdf_extract_tables': return createToolResult(await pdfExtractTables(args));
158
+ case 'pdf_find_table': return createToolResult(await pdfFindTable(args));
159
+ case 'pdf_ocr': return createToolResult(await pdfOcr(args));
160
+ case 'pdf_extract_table_details': return createToolResult(await pdfExtractTableDetails(args));
161
+ default: throw Object.assign(new Error(`Unknown tool: ${name}`), { code: -32601 });
162
+ }
163
+ }
164
+
165
+ async function pdfInspect(args) {
166
+ rejectUnexpectedArgs(args, ['input', 'returnContent', 'output']);
167
+ const input = path.resolve(requireString(args.input, 'input'));
168
+ requireString(args.output, 'output');
169
+ const result = await runJsonCandidateChain(pdfCandidates, ['inspect', input, '--json']);
170
+ const payload = result.json;
171
+ const document = payload?.document ?? payload;
172
+ if (!document || !Number.isInteger(document.pages) || document.pages < 1) {
173
+ throw new Error('pdf-inspect-runtime-result-invalid');
174
+ }
175
+ const metadata = document.metadata && typeof document.metadata === 'object' ? document.metadata : {};
176
+ const identity = {
177
+ format: 'pdf',
178
+ pageCount: document.pages,
179
+ title: nullableText(metadata.title),
180
+ author: nullableText(metadata.author),
181
+ subject: nullableText(metadata.subject),
182
+ imageCount: nonnegativeInteger(document.image_count),
183
+ wordCount: nonnegativeInteger(document.word_count),
184
+ scannedPageCount: nonnegativeInteger(document.scanned_page_count),
185
+ imageOnly: document.image_only === true,
186
+ openingPages: (Array.isArray(document.page_sizes) ? document.page_sizes : []).slice(0, 3)
187
+ .map((page) => ({
188
+ page: positiveInteger(page.page),
189
+ width: positiveNumber(page.width),
190
+ height: positiveNumber(page.height),
191
+ imageCount: nonnegativeInteger(page.image_count),
192
+ wordCount: nonnegativeInteger(page.word_count),
193
+ imageOnly: page.image_only === true,
194
+ })),
195
+ };
196
+ return deliverLargeJsonResult({
197
+ tool: 'pdf_inspect', args, runtime: commandRuntime(result), payload, sourcePaths: [input], identity,
198
+ });
199
+ }
200
+
201
+ async function pdfExtractTables(args) {
202
+ rejectUnexpectedArgs(args, ['input', 'pages', 'autoSpan', 'returnContent', 'output']);
203
+ const input = path.resolve(requireString(args.input, 'input'));
204
+ const commandArgs = ['extract-tables', input];
205
+ appendPdfFlags(commandArgs, args);
206
+ commandArgs.push('--json');
207
+ const result = await runJsonCandidateChain(pdfCandidates, commandArgs);
208
+ return deliverLargeJsonResult({
209
+ tool: 'pdf_extract_tables', args, runtime: commandRuntime(result), payload: result.json, sourcePaths: [input],
210
+ });
211
+ }
212
+
213
+ async function pdfFindTable(args) {
214
+ rejectUnexpectedArgs(args, ['input', 'name', 'autoSpan', 'returnContent', 'output']);
215
+ const input = path.resolve(requireString(args.input, 'input'));
216
+ const commandArgs = ['find-table', input, requireString(args.name, 'name')];
217
+ appendPdfFlags(commandArgs, args);
218
+ commandArgs.push('--json');
219
+ const result = await runJsonCandidateChain(pdfCandidates, commandArgs);
220
+ return deliverLargeJsonResult({
221
+ tool: 'pdf_find_table', args, runtime: commandRuntime(result), payload: result.json, sourcePaths: [input],
222
+ });
223
+ }
224
+
225
+ async function pdfExtractTableDetails(args) {
226
+ rejectUnexpectedArgs(args, ['input', 'pages', 'returnContent', 'output']);
227
+ const input = path.resolve(requireString(args.input, 'input'));
228
+ const commandArgs = ['extract-table-details', input];
229
+ appendPages(commandArgs, args.pages);
230
+ commandArgs.push('--json');
231
+ const result = await runJsonCandidateChain(pdfCandidates, commandArgs);
232
+ return deliverLargeJsonResult({
233
+ tool: 'pdf_extract_table_details', args, runtime: commandRuntime(result), payload: result.json, sourcePaths: [input],
234
+ });
235
+ }
236
+
237
+ async function pdfOcr(args) {
238
+ rejectUnexpectedArgs(args, ['input', 'output', 'pages', 'returnContent']);
239
+ const input = path.resolve(requireString(args.input, 'input'));
240
+ const commandArgs = ['ocr', input, '--provider', 'llm', '--llm-model', 'qwen3.8-max'];
241
+ appendPages(commandArgs, args.pages);
242
+ commandArgs.push('--json');
243
+ const result = await runJsonCandidateChain(pdfCandidates, commandArgs);
244
+ return deliverLargeJsonResult({
245
+ tool: 'pdf_ocr', args, runtime: commandRuntime(result), payload: result.json, sourcePaths: [input],
246
+ });
247
+ }
248
+
249
+ function rejectUnexpectedArgs(args, allowed) {
250
+ const unexpected = Object.keys(args).filter(key => !allowed.includes(key));
251
+ if (unexpected.length > 0) {
252
+ throw Object.assign(new Error(`Unexpected arguments: ${unexpected.join(', ')}`), { code: -32602 });
253
+ }
254
+ }
255
+
256
+ function appendPdfFlags(commandArgs, args) {
257
+ appendPages(commandArgs, args.pages);
258
+ if (args.autoSpan === true) commandArgs.push('--auto-span');
259
+ }
260
+
261
+ function appendPages(commandArgs, pages) {
262
+ if (Array.isArray(pages) && pages.length > 0) commandArgs.push('--pages', pages.join(','));
263
+ }
264
+
265
+ function commandRuntime(result) {
266
+ return { command: result.command, arguments: result.args };
267
+ }
268
+
269
+ function nullableText(value) {
270
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
271
+ }
272
+
273
+ function nonnegativeInteger(value) {
274
+ if (!Number.isInteger(value) || value < 0) throw new Error('pdf-inspect-runtime-result-invalid');
275
+ return value;
276
+ }
277
+
278
+ function positiveInteger(value) {
279
+ if (!Number.isInteger(value) || value < 1) throw new Error('pdf-inspect-runtime-result-invalid');
280
+ return value;
281
+ }
282
+
283
+ function positiveNumber(value) {
284
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
285
+ throw new Error('pdf-inspect-runtime-result-invalid');
286
+ }
287
+ return value;
288
+ }
289
+
290
+ function readJson(file) {
291
+ return JSON.parse(readFileSync(file, 'utf8'));
292
+ }
293
+
294
+ const server = new McpStdioServer({
295
+ name: 'tiwater-pdf',
296
+ version: packageJson.version,
297
+ instructions: [
298
+ 'Generic PDF inspection, deterministic table extraction, and OCR fixed to Aliyun qwen3.8-max through the per-invocation Supen Gateway credential.',
299
+ 'Every output path is an immutable observation artifact identity: an identical request may replay identical bytes, while every different result uses a different path.',
300
+ 'PDF tools report technical observations only and never assign business roles, source dispositions, or delivery decisions.',
301
+ ].join(' '),
302
+ tools,
303
+ callTool,
304
+ logger: message => process.stderr.write(`${message}\n`),
305
+ });
306
+
307
+ server.start();
package/text/README.md ADDED
@@ -0,0 +1,14 @@
1
+ # tiwater-text-mcp
2
+
3
+ `tiwater-text-mcp` publishes read-only observation for explicitly supported
4
+ plain-text files. `text_inspect` reports the exact byte identity, lossless
5
+ decoding facts, line count, and bounded opening lines. `text_read_lines` reads
6
+ one explicit zero-based line page and reports its continuation.
7
+
8
+ Supported inputs are `.txt`, `.text`, `.log`, `.csv`, `.tsv`, `.md`, and
9
+ `.markdown` files encoded as valid UTF-8, UTF-8 with BOM, UTF-16LE with BOM, or
10
+ UTF-16BE with BOM. The provider rejects binary content and never guesses an
11
+ encoding.
12
+
13
+ The tools do not interpret key-value pairs, records, sections, Markdown, or
14
+ business fields, and never modify or transcode the source.
@@ -0,0 +1,24 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "tiwater.text-mcp-input/text_inspect/v1",
4
+ "type": "object",
5
+ "properties": {
6
+ "input": {
7
+ "type": "string",
8
+ "minLength": 1,
9
+ "x-tiwater-file-role": "read"
10
+ },
11
+ "returnContent": {
12
+ "type": "boolean",
13
+ "description": "Return the complete bounded inspection directly."
14
+ },
15
+ "output": {
16
+ "type": "string",
17
+ "minLength": 1,
18
+ "description": "New immutable JSON artifact path for the complete inspection.",
19
+ "x-tiwater-file-role": "write"
20
+ }
21
+ },
22
+ "required": ["input", "returnContent", "output"],
23
+ "additionalProperties": false
24
+ }
@@ -0,0 +1,36 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "tiwater.text-mcp-input/text_read_lines/v1",
4
+ "type": "object",
5
+ "properties": {
6
+ "input": {
7
+ "type": "string",
8
+ "minLength": 1,
9
+ "x-tiwater-file-role": "read"
10
+ },
11
+ "offset": {
12
+ "type": "integer",
13
+ "minimum": 0,
14
+ "maximum": 9007199254740991,
15
+ "description": "Zero-based line offset in the exact current source revision."
16
+ },
17
+ "limit": {
18
+ "type": "integer",
19
+ "minimum": 1,
20
+ "maximum": 200,
21
+ "description": "Maximum number of lines in this bounded page."
22
+ },
23
+ "returnContent": {
24
+ "type": "boolean",
25
+ "description": "Return this selected line page directly when it fits the response limit. May be combined with output."
26
+ },
27
+ "output": {
28
+ "type": "string",
29
+ "minLength": 1,
30
+ "description": "Optional immutable JSON artifact path for this selected line page. May be combined with returnContent.",
31
+ "x-tiwater-file-role": "write"
32
+ }
33
+ },
34
+ "required": ["input", "offset", "limit", "returnContent"],
35
+ "additionalProperties": false
36
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "schema": "tiwater.text-provider-contract-manifest/v1",
3
+ "provider": {
4
+ "id": "@tiwater/office-mcp",
5
+ "version": "0.21.30"
6
+ },
7
+ "tools": [
8
+ {
9
+ "name": "text_inspect",
10
+ "providerContract": {
11
+ "source": "servers/text/provider-contracts/text_inspect.schema.json",
12
+ "sha256": "41d463771e4cf5d0c9377ba70a139ca043ca9542f074c7d6425f0b711cfa50b8"
13
+ },
14
+ "inputContract": {
15
+ "path": "text/contracts/text_inspect.schema.json",
16
+ "sha256": "41d463771e4cf5d0c9377ba70a139ca043ca9542f074c7d6425f0b711cfa50b8"
17
+ }
18
+ },
19
+ {
20
+ "name": "text_read_lines",
21
+ "providerContract": {
22
+ "source": "servers/text/provider-contracts/text_read_lines.schema.json",
23
+ "sha256": "0f9202f0b45bbd35b3ba4bebec7776da41f2c8668c7cdece068c44102f518bb6"
24
+ },
25
+ "inputContract": {
26
+ "path": "text/contracts/text_read_lines.schema.json",
27
+ "sha256": "0f9202f0b45bbd35b3ba4bebec7776da41f2c8668c7cdece068c44102f518bb6"
28
+ }
29
+ }
30
+ ]
31
+ }
package/text/index.mjs ADDED
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash } from 'node:crypto';
4
+ import { readFile } from 'node:fs/promises';
5
+ import * as z from 'zod/v4';
6
+ import { McpServer } from '@modelcontextprotocol/server';
7
+ import { serveStdio } from '@modelcontextprotocol/server/stdio';
8
+
9
+ import { createToolResult } from '../_shared/tool-runtime.mjs';
10
+ import { deliverLargeJsonResult } from '../_shared/large-json-result.mjs';
11
+ import { withOutputWriteLock } from '../_shared/output-write-lock.mjs';
12
+ import { inspectText, readTextLines } from './observation.mjs';
13
+
14
+ const packageMetadata = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8'));
15
+ const contractManifest = JSON.parse(await readFile(
16
+ new URL('./contracts/tiwater-text-provider-contract-manifest-v1.json', import.meta.url),
17
+ 'utf8',
18
+ ));
19
+ if (contractManifest.schema !== 'tiwater.text-provider-contract-manifest/v1'
20
+ || contractManifest.provider?.id !== packageMetadata.name
21
+ || contractManifest.provider?.version !== packageMetadata.version) {
22
+ throw new Error('Text MCP input contract manifest does not match the installed distribution');
23
+ }
24
+
25
+ const inputContracts = new Map(await Promise.all(contractManifest.tools.map(async entry => {
26
+ const bytes = await readFile(new URL(`./contracts/${entry.name}.schema.json`, import.meta.url));
27
+ const hash = createHash('sha256').update(bytes).digest('hex');
28
+ if (hash !== entry.inputContract.sha256) {
29
+ throw new Error(`Text MCP input contract hash mismatch: ${entry.name}`);
30
+ }
31
+ return [entry.name, z.fromJSONSchema(JSON.parse(bytes.toString('utf8')))];
32
+ })));
33
+
34
+ const openingLineLimit = 8;
35
+ const artifact = z.object({
36
+ path: z.string(),
37
+ sha256: z.string().regex(/^[0-9a-f]{64}$/),
38
+ bytes: z.number().int().nonnegative(),
39
+ }).strict();
40
+ const runtimeIdentity = z.object({ command: z.literal('tiwater-text-mcp'), cwd: z.string() }).strict();
41
+ const decoding = z.object({
42
+ status: z.literal('lossless'),
43
+ encoding: z.enum(['utf-8', 'utf-16le', 'utf-16be']),
44
+ bom: z.enum(['none', 'utf-8', 'utf-16le', 'utf-16be']),
45
+ }).strict();
46
+ const lineIdentity = z.object({
47
+ sourceSha256: z.string().regex(/^[0-9a-f]{64}$/),
48
+ index: z.number().int().nonnegative(),
49
+ }).strict();
50
+ const terminator = z.enum(['none', 'lf', 'crlf', 'cr']);
51
+ const openingLine = z.object({
52
+ identity: lineIdentity,
53
+ textPreview: z.string(),
54
+ textLength: z.number().int().nonnegative(),
55
+ terminator,
56
+ }).strict();
57
+ const inspectIdentity = z.object({
58
+ source: artifact,
59
+ extension: z.string(),
60
+ decoding,
61
+ lineCount: z.number().int().nonnegative(),
62
+ openingLines: z.array(openingLine).max(openingLineLimit),
63
+ }).strict();
64
+ const linePageReceipt = z.object({
65
+ schema: z.literal('tiwater.text-line-page-receipt/v1'),
66
+ totalLineCount: z.number().int().nonnegative(),
67
+ returnedLineCount: z.number().int().nonnegative(),
68
+ remaining: z.number().int().nonnegative(),
69
+ nextOffset: z.number().int().nonnegative().nullable(),
70
+ }).strict();
71
+ const textLine = z.object({ identity: lineIdentity, text: z.string(), terminator }).strict();
72
+ const inspectContent = z.object({
73
+ schema: z.literal('tiwater.text-inspection/v1'),
74
+ source: artifact,
75
+ extension: z.string(),
76
+ decoding,
77
+ lineCount: z.number().int().nonnegative(),
78
+ openingLines: z.array(openingLine).max(openingLineLimit),
79
+ }).strict();
80
+ const linePage = z.object({
81
+ schema: z.literal('tiwater.text-line-page/v1'),
82
+ source: artifact,
83
+ extension: z.string(),
84
+ decoding,
85
+ receipt: linePageReceipt,
86
+ lines: z.array(textLine).max(200),
87
+ }).strict();
88
+
89
+ function largeResultOutput(contentSchema) {
90
+ return z.object({
91
+ tool: z.string(),
92
+ runtime: runtimeIdentity,
93
+ sources: z.array(artifact).min(1).max(1),
94
+ returnContent: z.boolean(),
95
+ artifact: artifact.nullable(),
96
+ receipt: z.object({
97
+ contentBytes: z.number().int().nonnegative(),
98
+ contentReturned: z.boolean(),
99
+ contentWritten: z.boolean(),
100
+ }).strict(),
101
+ content: contentSchema.optional(),
102
+ }).strict();
103
+ }
104
+
105
+ const definitions = [
106
+ {
107
+ name: 'text_inspect',
108
+ description: 'Inspect one exact supported plain-text revision. Return its byte identity, lossless encoding and BOM facts, line count, and at most eight opening line identities while retaining the complete bounded inspection at output. It does not parse fields, records, key-value pairs, sections, or markup.',
109
+ outputSchema: largeResultOutput(inspectContent).extend({ identity: inspectIdentity }).strict(),
110
+ handler: textInspect,
111
+ },
112
+ {
113
+ name: 'text_read_lines',
114
+ description: 'Read one explicit zero-based line page from one exact supported plain-text revision. The receipt reports remaining lines and nextOffset; continue only when another line is needed. Set returnContent true to return the selected page when it fits the response limit. Provide output to retain the same complete page. These channels are independent and may be used together; at least one is required. Lines retain their exact decoded text and terminator; the provider does not interpret fields, records, key-value pairs, sections, Markdown, or business meaning.',
115
+ outputSchema: largeResultOutput(linePage).extend({ summary: linePageReceipt }).strict(),
116
+ handler: textReadLines,
117
+ },
118
+ ];
119
+
120
+ function buildServer() {
121
+ const server = new McpServer(
122
+ { name: 'tiwater-text', version: packageMetadata.version },
123
+ { instructions: 'Observe only exact supported plain-text bytes and explicit zero-based line pages. A read-only output path is an immutable artifact identity: an identical request may replay it; every different request uses a different path. Callers own all interpretation and business meaning.' },
124
+ );
125
+ for (const definition of definitions) {
126
+ const inputSchema = inputContracts.get(definition.name);
127
+ if (!inputSchema) throw new Error(`Missing provider-owned Text MCP input contract: ${definition.name}`);
128
+ server.registerTool(
129
+ definition.name,
130
+ {
131
+ description: definition.description,
132
+ inputSchema,
133
+ outputSchema: definition.outputSchema,
134
+ annotations: {
135
+ readOnlyHint: true,
136
+ idempotentHint: true,
137
+ destructiveHint: false,
138
+ openWorldHint: false,
139
+ },
140
+ },
141
+ async args => {
142
+ const payload = typeof args.output === 'string'
143
+ ? await withOutputWriteLock(args.output, () => definition.handler(args))
144
+ : await definition.handler(args);
145
+ return createToolResult(payload);
146
+ },
147
+ );
148
+ }
149
+ return server;
150
+ }
151
+
152
+ async function textInspect(args) {
153
+ const observation = await inspectText(args.input);
154
+ const delivered = await deliverLargeJsonResult({
155
+ tool: 'text_inspect',
156
+ args,
157
+ runtime: runtime(),
158
+ payload: observation.payload,
159
+ sourcePaths: [observation.input],
160
+ });
161
+ return { ...delivered, identity: observation.identity };
162
+ }
163
+
164
+ async function textReadLines(args) {
165
+ const observation = await readTextLines(args.input, args.offset, args.limit);
166
+ const delivered = await deliverLargeJsonResult({
167
+ tool: 'text_read_lines',
168
+ args,
169
+ runtime: runtime(),
170
+ payload: observation.payload,
171
+ sourcePaths: [observation.input],
172
+ });
173
+ return { ...delivered, summary: observation.receipt };
174
+ }
175
+
176
+ function runtime() {
177
+ return { command: 'tiwater-text-mcp', cwd: process.cwd() };
178
+ }
179
+
180
+ serveStdio(buildServer);
@@ -0,0 +1,162 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { fileArtifact } from '../_shared/large-json-result.mjs';
5
+
6
+ const supportedExtensions = new Set(['.txt', '.text', '.log', '.csv', '.tsv', '.md', '.markdown']);
7
+ const openingLineLimit = 8;
8
+ const openingTextLimit = 160;
9
+
10
+ export async function inspectText(inputValue) {
11
+ const observation = await observeText(inputValue);
12
+ const source = await fileArtifact(observation.input);
13
+ const openingLines = observation.lines.slice(0, openingLineLimit).map(line => ({
14
+ identity: { sourceSha256: source.sha256, index: line.index },
15
+ textPreview: preview(line.text, openingTextLimit),
16
+ textLength: [...line.text].length,
17
+ terminator: line.terminator,
18
+ }));
19
+ const identity = {
20
+ source,
21
+ extension: observation.extension,
22
+ decoding: observation.decoding,
23
+ lineCount: observation.lines.length,
24
+ openingLines,
25
+ };
26
+ return {
27
+ input: observation.input,
28
+ identity,
29
+ payload: { schema: 'tiwater.text-inspection/v1', ...identity },
30
+ };
31
+ }
32
+
33
+ export async function readTextLines(inputValue, requestedOffset, requestedLimit) {
34
+ if (!Number.isSafeInteger(requestedOffset) || requestedOffset < 0) {
35
+ throw Object.assign(new Error('offset must be a non-negative safe integer'), { code: -32602 });
36
+ }
37
+ if (!Number.isSafeInteger(requestedLimit) || requestedLimit < 1 || requestedLimit > 200) {
38
+ throw Object.assign(new Error('limit must be a safe integer from 1 through 200'), { code: -32602 });
39
+ }
40
+ const observation = await observeText(inputValue);
41
+ const source = await fileArtifact(observation.input);
42
+ const offset = Math.min(requestedOffset, observation.lines.length);
43
+ const selected = observation.lines.slice(offset, offset + requestedLimit);
44
+ const nextOffset = offset + selected.length < observation.lines.length
45
+ ? offset + selected.length
46
+ : null;
47
+ const receipt = {
48
+ schema: 'tiwater.text-line-page-receipt/v1',
49
+ totalLineCount: observation.lines.length,
50
+ returnedLineCount: selected.length,
51
+ remaining: observation.lines.length - offset - selected.length,
52
+ nextOffset,
53
+ };
54
+ return {
55
+ input: observation.input,
56
+ receipt,
57
+ payload: {
58
+ schema: 'tiwater.text-line-page/v1',
59
+ source,
60
+ extension: observation.extension,
61
+ decoding: observation.decoding,
62
+ receipt,
63
+ lines: selected.map(line => ({
64
+ identity: { sourceSha256: source.sha256, index: line.index },
65
+ text: line.text,
66
+ terminator: line.terminator,
67
+ })),
68
+ },
69
+ };
70
+ }
71
+
72
+ export async function observeText(inputValue) {
73
+ if (typeof inputValue !== 'string' || inputValue.trim() === '') {
74
+ throw Object.assign(new Error('input must be a non-empty string'), { code: -32602 });
75
+ }
76
+ const input = path.resolve(inputValue);
77
+ const extension = path.extname(input).toLowerCase();
78
+ if (!supportedExtensions.has(extension)) {
79
+ throw Object.assign(new Error(`unsupported-plain-text-extension:${extension || '(none)'}`), { code: -32602 });
80
+ }
81
+ const bytes = await readFile(input);
82
+ const decoded = decodeLosslessly(bytes);
83
+ rejectBinaryControls(decoded.text);
84
+ return { input, extension, decoding: decoded.decoding, lines: splitLines(decoded.text) };
85
+ }
86
+
87
+ function decodeLosslessly(bytes) {
88
+ if (bytes.subarray(0, 3).equals(Buffer.from([0xef, 0xbb, 0xbf]))) {
89
+ return decodeWithRoundTrip(bytes.subarray(3), 'utf-8', 'utf-8');
90
+ }
91
+ if (bytes.subarray(0, 2).equals(Buffer.from([0xff, 0xfe]))) {
92
+ return decodeWithRoundTrip(bytes.subarray(2), 'utf-16le', 'utf-16le');
93
+ }
94
+ if (bytes.subarray(0, 2).equals(Buffer.from([0xfe, 0xff]))) {
95
+ return decodeWithRoundTrip(bytes.subarray(2), 'utf-16be', 'utf-16be');
96
+ }
97
+ return decodeWithRoundTrip(bytes, 'utf-8', 'none');
98
+ }
99
+
100
+ function decodeWithRoundTrip(bytes, encoding, bom) {
101
+ if ((encoding === 'utf-16le' || encoding === 'utf-16be') && bytes.length % 2 !== 0) {
102
+ throw Object.assign(new Error(`invalid-${encoding}-byte-length`), { code: -32602 });
103
+ }
104
+ let text;
105
+ try {
106
+ text = new TextDecoder(encoding, { fatal: true }).decode(bytes);
107
+ } catch {
108
+ throw Object.assign(new Error(`invalid-${encoding}-sequence`), { code: -32602 });
109
+ }
110
+ let encoded = encoding === 'utf-8' ? Buffer.from(text, 'utf8') : Buffer.from(text, 'utf16le');
111
+ if (encoding === 'utf-16be') encoded = swapUtf16Bytes(encoded);
112
+ if (!encoded.equals(bytes)) {
113
+ throw Object.assign(new Error(`non-lossless-${encoding}-decode`), { code: -32602 });
114
+ }
115
+ return { text, decoding: { status: 'lossless', encoding, bom } };
116
+ }
117
+
118
+ function swapUtf16Bytes(bytes) {
119
+ const swapped = Buffer.allocUnsafe(bytes.length);
120
+ for (let index = 0; index < bytes.length; index += 2) {
121
+ swapped[index] = bytes[index + 1];
122
+ swapped[index + 1] = bytes[index];
123
+ }
124
+ return swapped;
125
+ }
126
+
127
+ function rejectBinaryControls(text) {
128
+ const binary = [...text].some(character => {
129
+ const code = character.codePointAt(0);
130
+ return code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31)
131
+ || (code >= 127 && code <= 159);
132
+ });
133
+ if (binary) throw Object.assign(new Error('binary-control-content-is-not-plain-text'), { code: -32602 });
134
+ }
135
+
136
+ function splitLines(text) {
137
+ if (text.length === 0) return [];
138
+ const lines = [];
139
+ let start = 0;
140
+ while (start < text.length) {
141
+ let end = start;
142
+ while (end < text.length && text[end] !== '\r' && text[end] !== '\n') end++;
143
+ let terminator = 'none';
144
+ let next = end;
145
+ if (end < text.length) {
146
+ if (text[end] === '\r' && text[end + 1] === '\n') {
147
+ terminator = 'crlf';
148
+ next = end + 2;
149
+ } else {
150
+ terminator = text[end] === '\r' ? 'cr' : 'lf';
151
+ next = end + 1;
152
+ }
153
+ }
154
+ lines.push({ index: lines.length, text: text.slice(start, end), terminator });
155
+ start = next;
156
+ }
157
+ return lines;
158
+ }
159
+
160
+ function preview(text, limit) {
161
+ return [...text].slice(0, limit).join('');
162
+ }