@pdpp/mcp-server 0.0.0 → 0.1.0-beta.8
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 +213 -0
- package/bin/pdpp-mcp-server.js +12 -0
- package/package.json +47 -3
- package/src/credentials.js +99 -0
- package/src/index.js +141 -0
- package/src/rs-client.js +167 -0
- package/src/server.js +162 -0
- package/src/tools.js +2074 -0
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@beta`](https://www.npmjs.com/package/@pdpp/mcp-server).
|
|
25
|
+
Follow the [package release policy](../../docs/package-release-policy.md) — use the `@beta`
|
|
26
|
+
dist-tag until a stable release is cut. Matches the posture of `@pdpp/cli` and
|
|
27
|
+
`@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@beta", "--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.
|
|
4
|
-
"description": "Local stdio MCP adapter for
|
|
3
|
+
"version": "0.1.0-beta.8",
|
|
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": "beta"
|
|
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';
|
package/src/rs-client.js
ADDED
|
@@ -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
|
+
}
|