@pdpp/mcp-server 0.0.0 → 0.1.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 ADDED
@@ -0,0 +1,213 @@
1
+ # @pdpp/mcp-server
2
+
3
+ Local stdio and hosted Streamable HTTP [Model Context Protocol](https://modelcontextprotocol.io/)
4
+ adapter for grant-scoped access to a [PDPP](https://pdpp.vivid.fish) resource server.
5
+
6
+ The adapter is a thin client of the PDPP resource server (RS). It does not run connectors,
7
+ issue grants, or replicate any RS authorization logic. Every data-bearing tool call is a
8
+ forwarded request to an existing `/v1/*` endpoint, authenticated with the scoped client
9
+ access token already cached by `pdpp connect`. The MCP setup is a profile-free normal
10
+ read surface; event-subscription management is not part of the recommended MCP tool list.
11
+
12
+ ## What this is not
13
+
14
+ - **Not a grant-issuance surface.** If the cache is empty or the token is invalid, the
15
+ adapter exits / surfaces an error directing the operator at `pdpp connect`.
16
+ - **Not an owner-mode bypass.** `PDPP_OWNER_TOKEN` and other owner credentials are
17
+ refused by default.
18
+ - **Not a proxy.** Per-client consent and confused-deputy mitigations would be required
19
+ before this package accepted unvalidated MCP-client tokens. Hosted callers must
20
+ validate the bearer before passing it to this package.
21
+
22
+ ## Publication status
23
+
24
+ Published to npm as [`@pdpp/mcp-server`](https://www.npmjs.com/package/@pdpp/mcp-server).
25
+ Follow the [package release policy](../../docs/package-release-policy.md) — a single
26
+ release channel publishes 0.x versions to npm's default `latest` dist-tag.
27
+ Matches the posture of `@pdpp/cli` and `@pdpp/local-collector`.
28
+
29
+ ## Install (local agent harness)
30
+
31
+ ```jsonc
32
+ // claude_desktop_config.json (or equivalent)
33
+ {
34
+ "mcpServers": {
35
+ "pdpp": {
36
+ "command": "npx",
37
+ "args": ["-y", "@pdpp/mcp-server", "--provider-url", "https://pdpp.example.com"]
38
+ }
39
+ }
40
+ }
41
+ ```
42
+
43
+ Run `pdpp connect https://pdpp.example.com` first so a scoped client token is cached at
44
+ `.pdpp/clients/<host>.json`.
45
+
46
+ ## CLI
47
+
48
+ ```
49
+ pdpp-mcp-server --provider-url <url> [--cache-root <dir>] [--server-name <name>]
50
+ ```
51
+
52
+ Flags can also come from environment variables: `PDPP_PROVIDER_URL`,
53
+ `PDPP_CACHE_ROOT`, `PDPP_MCP_SERVER_NAME`.
54
+
55
+ The adapter writes only MCP protocol messages to stdout. Diagnostics go to stderr.
56
+
57
+ ## Tools
58
+
59
+ ### Read tools (read-only, idempotent)
60
+
61
+ | Tool | RS endpoint |
62
+ | --- | --- |
63
+ | `schema` | `GET /v1/schema` |
64
+ | `query_records` | `GET /v1/streams/{stream}/records` |
65
+ | `aggregate` | `GET /v1/streams/{stream}/aggregate` |
66
+ | `search` | `GET /v1/search` |
67
+ | `fetch` | `GET /v1/streams/{stream}/records/{record_id}` |
68
+
69
+ Plus one resource template: `pdpp://stream/{name}` → `GET /v1/streams/{name}`.
70
+
71
+ `search` preserves the RS envelope in `structuredContent.data` and also returns
72
+ ChatGPT-compatible `structuredContent.results[]` entries with `id`, `title`, `url`,
73
+ and available source handles such as `connection_id`. Its `content[]` text also
74
+ previews a bounded set of top hits so clients that cannot inspect structured
75
+ tool output can still fetch a result.
76
+ `fetch` accepts result ids in `stream:record_id` form and follows the
77
+ MCP/OpenAI search-fetch document contract: `structuredContent` is exactly
78
+ `id`, `title`, `text`, `url`, and `metadata`, and `content[]` contains the same
79
+ object as JSON text for hosts that hide structured output. It does not return a
80
+ canonical PDPP record envelope under `structuredContent.data`; use
81
+ `query_records` for canonical structured record reads. `fetch(fields)` projects
82
+ the source record before rendering the document so unrequested source-native
83
+ payload fields do not leak into `text` or `metadata`. If that projection omits
84
+ every text-like field (`text`, `content`, `body`, `summary`), the document
85
+ `text` contains compact JSON for the projected record rather than the full
86
+ document body; source handles such as stream, `connection_id`, and
87
+ `connector_key` remain in `metadata`.
88
+
89
+ The `schema` tool includes concise parseable text with stream
90
+ names, `connection_id`, `connector_key`, display labels, and schema field-capability
91
+ essentials so MCP clients whose models read only `content[]` can still choose streams,
92
+ fields, and connection scopes.
93
+
94
+ `schema` defaults to a **compact** schema document under `structuredContent.data`
95
+ (`detail: "compact"`). It does not wrap the REST `/v1/schema` body again, so
96
+ callers read `structuredContent.data.connectors`, not `structuredContent.data.data.connectors`.
97
+ A real owner's grant-scoped `GET /v1/schema` body can exceed 2 MB once every connector
98
+ advertises full per-field JSON Schema, which is too large as the default agent-facing
99
+ payload. The compact projection collapses each field to a terse capability flag string
100
+ (declared type, grant, and usable filter/search/aggregation flags — e.g.
101
+ `type=string,granted=true,exact,range=gte|lt,agg=group_by_time`) and drops the raw
102
+ per-field JSON Schema, while preserving connection identities (`connection_id`,
103
+ `display_name`) and canonical `connector_key` metadata. This keeps
104
+ the discovery path `schema -> schema(stream) -> schema(stream, connection_id) -> query_records`
105
+ cheap: the package-level text summary lists streams without per-field flags and
106
+ points the agent at scoped schema calls for them. Stream names are not globally
107
+ unique; `schema(stream)` returns every granted connector/connection with that
108
+ stream name. Add `connection_id` when you need one configured source, especially
109
+ before requesting `detail: "full"`. Pass `detail: "full"` only together with
110
+ `stream`; if that stream name spans multiple sources, retry with `connection_id`
111
+ to get deduped exhaustive schema for one configured source. Full detail
112
+ preserves raw per-field JSON Schema and structured capability sub-objects, but
113
+ does not repeat the same selected stream list in both top-level and
114
+ connector-nested locations.
115
+
116
+ `query_records` also preserves the full RS envelope in `structuredContent.data`, and its
117
+ text content includes a bounded preview of returned records. This keeps agents that can
118
+ only reason over MCP `content[]` from seeing only a count summary while preserving
119
+ `structuredContent` as the canonical machine-readable result.
120
+
121
+ The `query_records` page is bounded by the spec-core §8 contract: omitting `limit` returns
122
+ at most 25 records, and `limit` is capped at 100. This tool advertises `100` as the input
123
+ maximum and rejects a larger `limit` at input validation, so the page size you request is
124
+ the page size you get — page forward with the returned `cursor` rather than asking for a
125
+ bigger page. (A direct REST client that sends `limit > 100` is clamped to 100 and told so
126
+ via a `limit_clamped` entry in the response `meta.warnings[]`; the cap is never silent on
127
+ either surface.)
128
+
129
+ ### Filtering (`query_records`, `aggregate`, `search`)
130
+
131
+ Pass `filter` as a **typed object**, not a pre-encoded query string. The adapter
132
+ encodes it into the resource server's `filter[field]=value` (exact) and
133
+ `filter[field][op]=value` (range) parameters for you:
134
+
135
+ ```jsonc
136
+ // exact match
137
+ { "filter": { "user_id": "U123" } }
138
+ // range (operators: gte, gt, lte, lt; AND together across fields)
139
+ { "filter": { "created_at": { "gte": "2026-01-01T00:00:00Z", "lt": "2026-02-01T00:00:00Z" } } }
140
+ ```
141
+
142
+ Allowed fields and operators are advertised per stream by `GET /v1/schema`
143
+ (`field_capabilities`) — discover them with the cheap
144
+ `schema -> schema(stream) -> schema(stream, connection_id) -> query_records`
145
+ path before constructing a filter. A legacy raw string using literal bracket syntax
146
+ (`"filter[user_id]=U123"`) is still accepted and parsed. Any other string shape
147
+ (a bare term like `"Vana"`, `"field=value"`, `"amount>100"`, or JSON encoded as a
148
+ string) is **rejected with a typed `invalid_filter` error** — it is never
149
+ silently forwarded as a bare `filter=` parameter (which the resource server
150
+ ignores). `aggregate` and `search` accept the same typed `filter` input.
151
+
152
+ `expand_limit` is typed the same way: pass an object keyed by relation name, for
153
+ example `{ "expand": ["messages"], "expand_limit": { "messages": 3 } }`. The
154
+ adapter encodes it as `expand_limit[messages]=3`; do not pre-encode bracket keys.
155
+
156
+ `aggregate` is the token-efficient way to answer count / sum / min / max / distinct-count and
157
+ grouped or time-bucketed rollup questions. It returns small bucket rows from
158
+ `GET /v1/streams/{stream}/aggregate`, never record bodies — so an agent that needs "how many
159
+ orders", "total spend by month", or "distinct senders" should call `aggregate` instead of
160
+ paging `query_records` and counting client-side. Group with exactly one dimension per call
161
+ (`group_by` for a scalar field XOR `group_by_time` + `granularity` for a date field).
162
+ Groupable, time-bucketable, and distinct-able fields are advertised by `GET /v1/schema`
163
+ (`field_capabilities.*.aggregation`). The aggregate tool result `content[]` text includes
164
+ the metric, stream, and numeric result (or a compact preview of grouped buckets with their
165
+ counts) so an agent that can read only `content[]` still gets the answer; the canonical
166
+ envelope remains in `structuredContent.data`.
167
+
168
+ `search` is bounded the same way: omitting `limit` returns at most 25 hits, and `limit` is
169
+ capped at 100 — the bound the published `/v1/search`, `/v1/search/semantic`, and
170
+ `/v1/search/hybrid` contract declares and every mode honors (advertised as
171
+ `capabilities.{lexical,semantic,hybrid}_retrieval.max_limit`). This tool advertises `100` as
172
+ the input maximum and rejects a larger `limit` at input validation, so the page size you
173
+ request is the page size you get — page forward with the returned `cursor` (lexical and
174
+ semantic; hybrid does not page) rather than asking for a bigger page. The MCP input cap is
175
+ the primary safeguard against an agent silently losing the page size it asked for: an over-cap
176
+ `limit` never reaches the RS from this tool. A _direct REST_ caller that sends `limit > 100`
177
+ is still served a bounded page, and — like `query_records` — now receives a structured
178
+ `limit_clamped` warning in `meta.warnings[]` (carrying `detail.requested_limit` /
179
+ `detail.max_limit`) on all three search modes, so the clamp is never silent on any read
180
+ surface.
181
+
182
+ Use lexical search for exact known terms. Semantic search is approximate
183
+ retrieval; it can surface conceptually related records but is not a replacement
184
+ for exact-term lookup when a user names a literal string.
185
+
186
+ When a response or typed `ambiguous_connection` error includes both `connection_id` and
187
+ `grant_id`, use `connection_id` as the stable data-source selector. `grant_id` identifies
188
+ the current authorization grant and can change when the owner reconnects or re-authorizes
189
+ the client. Do not persist `grant_id` as a reconnect-stable source identifier.
190
+
191
+ ## Hosted Streamable HTTP helper
192
+
193
+ Reference servers can mount hosted MCP by authenticating the incoming bearer themselves,
194
+ then passing a Web `Request` plus scoped token to `handleStreamableHttpRequest()`:
195
+
196
+ ```js
197
+ import { handleStreamableHttpRequest } from '@pdpp/mcp-server';
198
+
199
+ const response = await handleStreamableHttpRequest(request, {
200
+ providerUrl: 'https://pdpp.example.com',
201
+ accessToken: scopedClientBearer,
202
+ });
203
+ ```
204
+
205
+ The helper creates a fresh server and Streamable HTTP transport per request with MCP
206
+ session ids disabled. `/mcp` serves the profile-free normal read surface.
207
+
208
+ ## Errors
209
+
210
+ Resource-server error responses (4xx/5xx including `invalid_token`, `insufficient_scope`,
211
+ `needs_broader_grant`, `invalid_cursor`) are surfaced as MCP `isError: true` results with
212
+ the original envelope preserved in `structuredContent.error`. The adapter does not retry
213
+ with broader credentials.
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+ import { runMcpServerCli } from '../src/index.js';
3
+
4
+ runMcpServerCli(process.argv.slice(2)).then(
5
+ (code) => {
6
+ process.exit(code);
7
+ },
8
+ (error) => {
9
+ process.stderr.write(`pdpp-mcp-server: ${error?.stack ?? error}\n`);
10
+ process.exit(1);
11
+ }
12
+ );
package/package.json CHANGED
@@ -1,9 +1,53 @@
1
1
  {
2
2
  "name": "@pdpp/mcp-server",
3
- "version": "0.0.0",
4
- "description": "Local stdio MCP adapter for PDPP grant-scoped reads. Placeholder release; real versions are published by the repository's semantic-release pipeline under the beta dist-tag.",
3
+ "version": "0.1.0",
4
+ "description": "Local stdio MCP adapter for grant-scoped PDPP reads and event-subscription management.",
5
+ "type": "module",
6
+ "bin": {
7
+ "pdpp-mcp-server": "bin/pdpp-mcp-server.js"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.js",
11
+ "./server": "./src/server.js"
12
+ },
13
+ "files": [
14
+ "bin/",
15
+ "src/",
16
+ "README.md"
17
+ ],
18
+ "scripts": {
19
+ "test": "node --test test/*.test.js",
20
+ "verify": "pnpm test && node bin/pdpp-mcp-server.js --help",
21
+ "pack:dry-run": "pnpm pack --dry-run"
22
+ },
23
+ "dependencies": {
24
+ "@modelcontextprotocol/sdk": "^1.29.0",
25
+ "@pdpp/cli": "workspace:*",
26
+ "zod": "^3.25.76"
27
+ },
28
+ "keywords": [
29
+ "pdpp",
30
+ "personal-data",
31
+ "mcp",
32
+ "model-context-protocol"
33
+ ],
5
34
  "license": "ISC",
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/vana-com/pdpp.git",
38
+ "directory": "packages/mcp-server"
39
+ },
40
+ "bugs": {
41
+ "url": "https://github.com/vana-com/pdpp/issues"
42
+ },
43
+ "homepage": "https://github.com/vana-com/pdpp#readme",
44
+ "engines": {
45
+ "node": ">=22.14.0"
46
+ },
6
47
  "publishConfig": {
7
- "access": "public"
48
+ "access": "public",
49
+ "provenance": false,
50
+ "registry": "https://registry.npmjs.org/",
51
+ "tag": "latest"
8
52
  }
9
53
  }
@@ -0,0 +1,99 @@
1
+ import { readStoredCredential } from '@pdpp/cli';
2
+
3
+ export class CredentialError extends Error {
4
+ constructor(code, message, exitCode = 78) {
5
+ super(message);
6
+ this.name = 'CredentialError';
7
+ this.code = code;
8
+ this.exitCode = exitCode;
9
+ }
10
+ }
11
+
12
+ /**
13
+ * Load a scoped PDPP client credential from the `pdpp connect` cache.
14
+ *
15
+ * Owner credentials are refused by default; the adapter uses a grant-scoped bearer
16
+ * token for PDPP reads and event-subscription management. The env-derived
17
+ * `PDPP_OWNER_TOKEN` is never consulted.
18
+ */
19
+ export async function loadScopedCredential(providerUrl, options = {}) {
20
+ if (!providerUrl) {
21
+ throw new CredentialError(
22
+ 'no_provider_url',
23
+ 'Provider URL required. Pass --provider-url <url> or set PDPP_PROVIDER_URL.',
24
+ 64
25
+ );
26
+ }
27
+
28
+ let result;
29
+ try {
30
+ result = await readStoredCredential(providerUrl, { cacheRoot: options.cacheRoot });
31
+ } catch (error) {
32
+ if (error?.code === 'not_connected') {
33
+ throw new CredentialError(
34
+ 'not_connected',
35
+ `No scoped PDPP credential cached for ${providerUrl}. Run \`pdpp connect ${providerUrl}\` and try again.`,
36
+ 78
37
+ );
38
+ }
39
+ if (error?.code === 'credential_expired') {
40
+ throw new CredentialError(
41
+ 'credential_expired',
42
+ `Cached PDPP credential for ${providerUrl} is expired. Run \`pdpp connect ${providerUrl}\` again.`,
43
+ 78
44
+ );
45
+ }
46
+ if (error?.code === 'credential_invalid') {
47
+ throw new CredentialError(
48
+ 'credential_invalid',
49
+ `Cached PDPP credential for ${providerUrl} is malformed; re-run \`pdpp connect ${providerUrl}\`.`,
50
+ 78
51
+ );
52
+ }
53
+ if (error?.code === 'invalid_provider_url') {
54
+ throw new CredentialError('invalid_provider_url', error.message, 64);
55
+ }
56
+ throw error;
57
+ }
58
+
59
+ const credential = result?.credential;
60
+ if (!credential?.access_token) {
61
+ throw new CredentialError(
62
+ 'credential_invalid',
63
+ `Cached PDPP credential for ${providerUrl} is missing an access token.`,
64
+ 78
65
+ );
66
+ }
67
+
68
+ if (isOwnerKind(credential)) {
69
+ throw new CredentialError(
70
+ 'owner_token_refused',
71
+ `Cached credential for ${providerUrl} is an owner token; owner credentials are refused by the MCP adapter.`,
72
+ 77
73
+ );
74
+ }
75
+
76
+ return {
77
+ providerUrl: result.providerUrl,
78
+ cacheFile: result.cacheFile,
79
+ accessToken: credential.access_token,
80
+ tokenType: credential.token_type ?? 'Bearer',
81
+ scope: credential.scope ?? result.payload?.scope ?? null,
82
+ grantId: credential.grant_id ?? result.payload?.grant_id ?? null,
83
+ };
84
+ }
85
+
86
+ function isOwnerKind(credential) {
87
+ if (!credential || typeof credential !== 'object') {
88
+ return false;
89
+ }
90
+ // The PDPP audit doc names `pdpp_token_kind=owner` as the owner-distinguishing claim
91
+ // on cached credentials. Treat any kind/role-shaped owner signal as a refusal trigger.
92
+ const flagged = [
93
+ credential.pdpp_token_kind,
94
+ credential.token_kind,
95
+ credential.kind,
96
+ credential.role,
97
+ ];
98
+ return flagged.some((value) => typeof value === 'string' && value.toLowerCase() === 'owner');
99
+ }
package/src/index.js ADDED
@@ -0,0 +1,141 @@
1
+ import { CredentialError, loadScopedCredential } from './credentials.js';
2
+ import {
3
+ DEFAULT_SERVER_NAME,
4
+ DEFAULT_SERVER_VERSION,
5
+ startStdioServer,
6
+ } from './server.js';
7
+
8
+ const HELP = `pdpp-mcp-server — local stdio MCP adapter over a PDPP resource server
9
+
10
+ Usage:
11
+ pdpp-mcp-server --provider-url <url> [--cache-root <dir>] [--server-name <name>]
12
+
13
+ Environment:
14
+ PDPP_PROVIDER_URL Default for --provider-url
15
+ PDPP_CACHE_ROOT Default for --cache-root (defaults to .pdpp)
16
+ PDPP_MCP_SERVER_NAME Default for --server-name
17
+
18
+ The adapter uses a grant-scoped client token for the profile-free normal PDPP read
19
+ surface. It refuses owner credentials and exits non-zero if no scoped grant token is
20
+ cached for the provider. Run \`pdpp connect <provider-url>\` first.
21
+
22
+ stdout is reserved for MCP protocol messages. Diagnostics go to stderr.
23
+ `;
24
+
25
+ /**
26
+ * Entry point used by both the published bin and tests.
27
+ *
28
+ * Resolves config from argv/env, loads the cached scoped client token, refuses owner
29
+ * credentials, and starts the stdio server. Returns the process exit code; callers are
30
+ * responsible for invoking process.exit().
31
+ */
32
+ export async function runMcpServerCli(argv, deps = {}) {
33
+ const stderr = deps.stderr ?? process.stderr;
34
+ const env = deps.env ?? process.env;
35
+ const load = deps.loadScopedCredential ?? loadScopedCredential;
36
+ const start = deps.startStdioServer ?? startStdioServer;
37
+
38
+ if (argv.includes('--help') || argv.includes('-h')) {
39
+ stderr.write(HELP);
40
+ return 0;
41
+ }
42
+ if (argv.includes('--version')) {
43
+ stderr.write(`${DEFAULT_SERVER_VERSION}\n`);
44
+ return 0;
45
+ }
46
+
47
+ let options;
48
+ try {
49
+ options = parseOptions(argv, env);
50
+ } catch (error) {
51
+ stderr.write(`pdpp-mcp-server: ${error.message}\n`);
52
+ stderr.write(HELP);
53
+ return error.exitCode ?? 64;
54
+ }
55
+
56
+ let credential;
57
+ try {
58
+ credential = await load(options.providerUrl, { cacheRoot: options.cacheRoot });
59
+ } catch (error) {
60
+ if (error instanceof CredentialError) {
61
+ stderr.write(`pdpp-mcp-server: ${error.message}\n`);
62
+ return error.exitCode;
63
+ }
64
+ stderr.write(`pdpp-mcp-server: ${error?.stack ?? error}\n`);
65
+ return 1;
66
+ }
67
+
68
+ stderr.write(
69
+ `pdpp-mcp-server: connected to ${credential.providerUrl} using scoped credential at ${credential.cacheFile}\n`
70
+ );
71
+
72
+ let handle;
73
+ try {
74
+ handle = await start({
75
+ providerUrl: credential.providerUrl,
76
+ accessToken: credential.accessToken,
77
+ serverName: options.serverName,
78
+ });
79
+ } catch (error) {
80
+ stderr.write(`pdpp-mcp-server: failed to start stdio server: ${error?.stack ?? error}\n`);
81
+ return 1;
82
+ }
83
+
84
+ // Block until the transport signals close (e.g. parent harness closes our stdin).
85
+ // Without this, the bin would exit immediately after wiring up the server and the
86
+ // child process would terminate before any MCP request could be processed.
87
+ if (handle && typeof handle.closed?.then === 'function') {
88
+ await handle.closed;
89
+ }
90
+
91
+ return 0;
92
+ }
93
+
94
+ export class OptionParseError extends Error {
95
+ constructor(message, exitCode = 64) {
96
+ super(message);
97
+ this.name = 'OptionParseError';
98
+ this.exitCode = exitCode;
99
+ }
100
+ }
101
+
102
+ export function parseOptions(argv, env) {
103
+ const providerUrl = readOption(argv, '--provider-url') ?? env.PDPP_PROVIDER_URL ?? '';
104
+ const cacheRoot = readOption(argv, '--cache-root') ?? env.PDPP_CACHE_ROOT ?? '.pdpp';
105
+ const serverName =
106
+ readOption(argv, '--server-name') ?? env.PDPP_MCP_SERVER_NAME ?? DEFAULT_SERVER_NAME;
107
+
108
+ if (!providerUrl) {
109
+ throw new OptionParseError('Missing --provider-url (or PDPP_PROVIDER_URL).');
110
+ }
111
+
112
+ if (env.PDPP_OWNER_TOKEN || env.PDPP_OWNER_SESSION_COOKIE) {
113
+ // Refuse to operate when an owner credential is in the environment even though
114
+ // we never consult it. Exposing the owner-mode self-export surface through MCP
115
+ // is the footgun the design forbids.
116
+ throw new OptionParseError(
117
+ 'Refusing to start: owner credentials (PDPP_OWNER_TOKEN / PDPP_OWNER_SESSION_COOKIE) are present in the environment. Unset them before running the MCP adapter.',
118
+ 77
119
+ );
120
+ }
121
+
122
+ return { providerUrl, cacheRoot, serverName };
123
+ }
124
+
125
+ function readOption(argv, name) {
126
+ const index = argv.indexOf(name);
127
+ if (index === -1) return undefined;
128
+ return argv[index + 1];
129
+ }
130
+
131
+ export { CredentialError, loadScopedCredential } from './credentials.js';
132
+ export {
133
+ createPdppMcpServer,
134
+ handleStreamableHttpRequest,
135
+ startStdioServer,
136
+ DEFAULT_SERVER_NAME,
137
+ DEFAULT_SERVER_VERSION,
138
+ PDPP_MCP_TOOL_NAMES,
139
+ } from './server.js';
140
+ export { RsClient } from './rs-client.js';
141
+ export { buildTools, buildStreamResourceTemplate, InvalidResourceUriError } from './tools.js';
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Thin client over the PDPP resource server. Every request attaches the configured
3
+ * scoped client bearer token; no token rotation or owner fallback happens here.
4
+ */
5
+ export class RsClient {
6
+ constructor({ providerUrl, accessToken, fetch = globalThis.fetch, userAgent }) {
7
+ if (typeof fetch !== 'function') {
8
+ throw new TypeError('RsClient requires a fetch implementation');
9
+ }
10
+ if (!providerUrl) {
11
+ throw new TypeError('RsClient requires providerUrl');
12
+ }
13
+ if (!accessToken) {
14
+ throw new TypeError('RsClient requires accessToken');
15
+ }
16
+ this.providerUrl = providerUrl.replace(/\/$/, '');
17
+ this.accessToken = accessToken;
18
+ this.fetch = fetch;
19
+ this.userAgent = userAgent ?? '@pdpp/mcp-server';
20
+ }
21
+
22
+ async getJson(path, { query, headers } = {}) {
23
+ const url = this.buildUrl(path, query);
24
+ const response = await this.fetch(url, {
25
+ method: 'GET',
26
+ headers: {
27
+ Accept: 'application/json',
28
+ Authorization: `Bearer ${this.accessToken}`,
29
+ 'User-Agent': this.userAgent,
30
+ ...(headers ?? {}),
31
+ },
32
+ });
33
+ return parseRsResponse(response, { expectJson: true });
34
+ }
35
+
36
+ async getRaw(path, { query, headers } = {}) {
37
+ const url = this.buildUrl(path, query);
38
+ const response = await this.fetch(url, {
39
+ method: 'GET',
40
+ headers: {
41
+ Authorization: `Bearer ${this.accessToken}`,
42
+ 'User-Agent': this.userAgent,
43
+ ...(headers ?? {}),
44
+ },
45
+ });
46
+ return parseRsResponse(response, { expectJson: false });
47
+ }
48
+
49
+ async postJson(path, { body, query, headers } = {}) {
50
+ return this.sendJson('POST', path, { body, query, headers });
51
+ }
52
+
53
+ async patchJson(path, { body, query, headers } = {}) {
54
+ return this.sendJson('PATCH', path, { body, query, headers });
55
+ }
56
+
57
+ async deleteJson(path, { query, headers } = {}) {
58
+ return this.sendJson('DELETE', path, { body: undefined, query, headers });
59
+ }
60
+
61
+ async sendJson(method, path, { body, query, headers } = {}) {
62
+ const url = this.buildUrl(path, query);
63
+ const hasBody = body !== undefined && body !== null;
64
+ const init = {
65
+ method,
66
+ headers: {
67
+ Accept: 'application/json',
68
+ Authorization: `Bearer ${this.accessToken}`,
69
+ 'User-Agent': this.userAgent,
70
+ ...(hasBody ? { 'Content-Type': 'application/json' } : {}),
71
+ ...(headers ?? {}),
72
+ },
73
+ };
74
+ if (hasBody) {
75
+ init.body = typeof body === 'string' ? body : JSON.stringify(body);
76
+ }
77
+ const response = await this.fetch(url, init);
78
+ return parseRsResponse(response, { expectJson: true });
79
+ }
80
+
81
+ buildUrl(path, query) {
82
+ const url = new URL(path.startsWith('/') ? path : `/${path}`, `${this.providerUrl}/`);
83
+ if (query && typeof query === 'object') {
84
+ for (const [key, value] of Object.entries(query)) {
85
+ appendQuery(url, key, value);
86
+ }
87
+ }
88
+ return url.toString();
89
+ }
90
+ }
91
+
92
+ function appendQuery(url, key, value) {
93
+ if (value === undefined || value === null) {
94
+ return;
95
+ }
96
+ if (Array.isArray(value)) {
97
+ for (const entry of value) {
98
+ if (entry === undefined || entry === null) continue;
99
+ url.searchParams.append(key, String(entry));
100
+ }
101
+ return;
102
+ }
103
+ if (typeof value === 'object') {
104
+ throw new TypeError(
105
+ `query parameter '${key}' must be a scalar or array; encode nested query shapes explicitly before calling RsClient`,
106
+ );
107
+ }
108
+ url.searchParams.append(key, String(value));
109
+ }
110
+
111
+ async function parseRsResponse(response, { expectJson }) {
112
+ const status = response.status;
113
+ const contentType = response.headers?.get?.('content-type') ?? '';
114
+ const requestId = response.headers?.get?.('x-request-id') ?? null;
115
+
116
+ if (status >= 200 && status < 300) {
117
+ if (expectJson) {
118
+ if (status === 204) {
119
+ return { ok: true, status, body: null, requestId, contentType };
120
+ }
121
+ const body = contentType.includes('application/json') ? await response.json() : await response.text();
122
+ return { ok: true, status, body, requestId, contentType };
123
+ }
124
+ const buffer = Buffer.from(await response.arrayBuffer());
125
+ return { ok: true, status, body: buffer, requestId, contentType };
126
+ }
127
+
128
+ let errorBody = null;
129
+ try {
130
+ if (contentType.includes('application/json')) {
131
+ errorBody = await response.json();
132
+ } else {
133
+ errorBody = await response.text();
134
+ }
135
+ } catch {
136
+ errorBody = null;
137
+ }
138
+
139
+ const envelope = normalizeErrorEnvelope(errorBody, status);
140
+ if (requestId && envelope && typeof envelope === 'object' && !envelope.request_id) {
141
+ envelope.request_id = requestId;
142
+ }
143
+
144
+ return { ok: false, status, error: envelope, requestId, contentType };
145
+ }
146
+
147
+ function normalizeErrorEnvelope(body, status) {
148
+ if (body && typeof body === 'object') {
149
+ if (body.error && typeof body.error === 'object') {
150
+ return body.error;
151
+ }
152
+ if (typeof body.error === 'string') {
153
+ return {
154
+ type: body.error,
155
+ code: body.error,
156
+ message: body.error_description ?? body.message ?? body.error,
157
+ };
158
+ }
159
+ return body;
160
+ }
161
+
162
+ return {
163
+ type: 'rs_error',
164
+ code: `http_${status}`,
165
+ message: typeof body === 'string' && body.length > 0 ? body : `Resource server returned HTTP ${status}`,
166
+ };
167
+ }