@gpsglobal-ai/gpsglobal 1.4.4 → 1.4.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog — gpsglobal
2
2
 
3
+ ## 1.4.6 — 2026-06-21
4
+
5
+ - **`get_fund_wiki` default `format=linked`** — `{P18}` → markdown link to LP Workspace `?tab=analyze#page=18`
6
+ - **`format=raw`** — byte-identical NMG wiki.md (MCP-17 parity); **`format=summary`** unchanged
7
+ - Edge cases: multi-page `{P3, P7}`, conflict `{P3≠P7}`, divider pages (no link), page clamp to `extracted_pages`
8
+ - Docs: [`docs/57-mcp/020-wiki-linked-citations.md`](../docs/57-mcp/020-wiki-linked-citations.md)
9
+
10
+ ## 1.4.5 — 2026-06-21
11
+
12
+ - **Production defaults:** `setup` and `login` use `https://lp.gpsglobal.ai` (not `localhost:8080`)
13
+ - **`setup` default mode:** `oauth` — Cursor/VS Code/GitHub get URL-only `https://lp.gpsglobal.ai/mcp`
14
+ - **Claude Desktop:** still gets stdio + `envFile` when mode is `oauth` (Desktop has no reliable HTTP OAuth)
15
+ - **`doctor`:** probes `${apiBase}/mcp` → expects 401 on production (not localhost:3100)
16
+ - Local dev: `GPS_API_BASE=http://localhost:8080 npx @gpsglobal-ai/gpsglobal setup --mode=stdio`
17
+
3
18
  ## 1.4.4 — 2026-06-18
4
19
 
5
20
  - CI: disable npm provenance (private GitHub repo — OIDC publish still works)
package/README.md CHANGED
@@ -28,21 +28,27 @@ Data never leaves GPS infrastructure except through your authenticated session
28
28
 
29
29
  ## Quick start (recommended)
30
30
 
31
- One command — browser sign-in, credentials saved locally, configs merged for major AI hosts:
31
+ One command — browser sign-in at **production**, credentials saved locally, configs merged for major AI hosts:
32
32
 
33
33
  ```bash
34
34
  npx @gpsglobal-ai/gpsglobal setup
35
35
  ```
36
36
 
37
- This writes `~/.gps/mcp.env` (mode `600`) and updates **Cursor**, **VS Code**, and **Claude Desktop** configs.
37
+ This opens `https://lp.gpsglobal.ai` for OAuth, writes `~/.gps/mcp.env` (mode `600`), and updates **Cursor** (OAuth URL), **VS Code**, **GitHub Copilot**, and **Claude Desktop** (stdio) configs.
38
38
 
39
39
  **Restart your AI tool**, then ask: *"List my GPS funds using gpsglobal"*.
40
40
 
41
+ **Local Docker dev** (backend on `localhost:8080`):
42
+
43
+ ```bash
44
+ GPS_API_BASE=http://localhost:8080 npx @gpsglobal-ai/gpsglobal setup --mode=stdio
45
+ ```
46
+
41
47
  ### Other commands
42
48
 
43
49
  | Goal | Command |
44
50
  |------|---------|
45
- | OAuth Connect (URL-only, no token in config) | `npx @gpsglobal-ai/gpsglobal setup --mode=oauth` |
51
+ | stdio-only (all hosts) | `npx @gpsglobal-ai/gpsglobal setup --mode=stdio` |
46
52
  | Verify install + backend reachability | `npx @gpsglobal-ai/gpsglobal doctor` |
47
53
  | Print config snippets for all hosts | `npx @gpsglobal-ai/gpsglobal print-config` |
48
54
  | Refresh credentials | `npx @gpsglobal-ai/gpsglobal login --browser --refresh` |
@@ -60,9 +66,9 @@ gpsglobal setup
60
66
 
61
67
  | Host | Setup mode | Config key |
62
68
  |------|------------|------------|
63
- | **Cursor** | `setup` or `setup --mode=oauth` | `gpsglobal` |
69
+ | **Cursor** | `setup` (default oauth) | `gpsglobal` |
64
70
  | **VS Code / GitHub Copilot** | `setup` | `gpsglobal` |
65
- | **Claude Desktop** | `setup` (stdio) | `gpsglobal` |
71
+ | **Claude Desktop** | `setup` (stdio via envFile) | `gpsglobal` |
66
72
  | **Claude Code** | `claude mcp add --transport http gpsglobal https://lp.gpsglobal.ai/mcp` | `gpsglobal` |
67
73
 
68
74
  **OAuth Connect (production):** hosts discover auth via `https://lp.gpsglobal.ai/mcp` — no manual JWT paste.
@@ -129,7 +135,7 @@ gpsglobal setup
129
135
  |-------|-----|
130
136
  | Tools return 401 | `npx @gpsglobal-ai/gpsglobal login --browser --refresh` |
131
137
  | Host shows disconnected | Restart the AI app after `setup` |
132
- | `doctor` fails | Ensure backend is running; check `GPS_API_BASE` in `~/.gps/mcp.env` |
138
+ | `doctor` fails | Ensure you are whitelisted on prod; check `GPS_API_BASE` in `~/.gps/mcp.env` (should be `https://lp.gpsglobal.ai`) |
133
139
  | OAuth Connect loop | Confirm Cursor/VS Code config is URL-only (no stale Bearer header) |
134
140
 
135
141
  ---
@@ -2,6 +2,7 @@ import axios from 'axios';
2
2
  import { GpsApiClient } from '../clients/gps-api.js';
3
3
  import { loadGpsConfig, normalizeApiBase, DEFAULT_ENV_PATH } from '../config/env.js';
4
4
  import { decodeGpsJwt } from '../lib/jwt.js';
5
+ import { resolveMcpPublicUrl } from '../lib/host-config.js';
5
6
  export async function runDoctor() {
6
7
  const checks = [];
7
8
  let config;
@@ -53,22 +54,29 @@ export async function runDoctor() {
53
54
  detail: err instanceof Error ? err.message : String(err),
54
55
  });
55
56
  }
56
- const mcpUrl = process.env.GPS_MCP_PUBLIC_URL ?? 'http://localhost:3100';
57
+ const mcpUrl = resolveMcpPublicUrl(config.apiBase);
57
58
  try {
58
- const mcpHealth = await axios.get(`${mcpUrl}/health`, { timeout: 5_000, validateStatus: () => true });
59
- if (mcpHealth.status === 404) {
60
- checks.push({ name: 'mcp-http', ok: true, detail: 'not running (stdio mode OK)' });
59
+ const mcpProbe = await axios.get(`${mcpUrl}/mcp`, {
60
+ timeout: 10_000,
61
+ validateStatus: () => true,
62
+ maxRedirects: 0,
63
+ });
64
+ if (mcpProbe.status === 401) {
65
+ checks.push({ name: 'mcp-http', ok: true, detail: `${mcpUrl}/mcp → 401 (discovery OK)` });
66
+ }
67
+ else if (mcpProbe.status === 404) {
68
+ checks.push({ name: 'mcp-http', ok: true, detail: 'stdio mode (no remote /mcp)' });
61
69
  }
62
70
  else {
63
71
  checks.push({
64
72
  name: 'mcp-http',
65
- ok: mcpHealth.status === 200,
66
- detail: `${mcpUrl}/health → ${mcpHealth.status}`,
73
+ ok: mcpProbe.status === 200,
74
+ detail: `${mcpUrl}/mcp → ${mcpProbe.status}`,
67
75
  });
68
76
  }
69
77
  }
70
78
  catch {
71
- checks.push({ name: 'mcp-http', ok: true, detail: 'not running (stdio mode OK)' });
79
+ checks.push({ name: 'mcp-http', ok: true, detail: 'remote MCP unreachable (stdio mode OK)' });
72
80
  }
73
81
  return { ok: checks.every((c) => c.ok), checks };
74
82
  }
package/dist/cli/setup.js CHANGED
@@ -1,13 +1,14 @@
1
1
  import { browserLogin } from './browser-login.js';
2
- import { DEFAULT_ENV_PATH, normalizeApiBase } from '../config/env.js';
2
+ import { DEFAULT_ENV_PATH, LOCAL_GPS_API_BASE, normalizeApiBase, resolveDefaultApiBase } from '../config/env.js';
3
3
  import { MCP_SERVER_KEY, PACKAGE, claudeCodeAddCommand, mergeVsCodeConfig, mergeVsCodeWorkspaceConfig, mergeClaudeDesktopConfig, mergeCursorConfig, buildOAuthConnectEntry, buildRemoteEntry, buildStdioEntry, resolveMcpPublicUrl, writeAllHostConfigs, } from '../lib/host-config.js';
4
4
  export async function runSetup(options) {
5
- const apiBase = normalizeApiBase(options.apiBase ?? process.env.GPS_API_BASE ?? 'http://localhost:8080');
5
+ const apiBase = normalizeApiBase(options.apiBase ?? resolveDefaultApiBase());
6
6
  const host = options.host ?? 'all';
7
7
  const useBrowser = options.useBrowser ?? true;
8
- const mode = options.mode ?? 'stdio';
8
+ const mode = options.mode ?? 'oauth';
9
9
  const mcpPublic = resolveMcpPublicUrl(apiBase);
10
- console.log(`GPS MCP setup — configure ${MCP_SERVER_KEY} for your AI tools\n`);
10
+ console.log(`GPS MCP setup — configure ${MCP_SERVER_KEY} for your AI tools`);
11
+ console.log(` API: ${apiBase} · MCP: ${mcpPublic}/mcp · mode: ${mode}\n`);
11
12
  if (useBrowser) {
12
13
  const result = await browserLogin(apiBase);
13
14
  console.log(`\n✓ Signed in as ${result.username} (${result.lpId})`);
@@ -48,5 +49,6 @@ export async function runSetup(options) {
48
49
  console.log(`\nRun:\n ${claudeCodeAddCommand(mcpPublic, mode === 'oauth' ? 'oauth' : 'remote')}`);
49
50
  }
50
51
  console.log(`\nVerify: npx ${PACKAGE} doctor`);
52
+ console.log(`Local dev: GPS_API_BASE=${LOCAL_GPS_API_BASE} npx ${PACKAGE} setup --mode=stdio`);
51
53
  console.log(`Done. Ask your AI: "list my GPS funds using ${MCP_SERVER_KEY}"`);
52
54
  }
package/dist/cli.js CHANGED
@@ -2,14 +2,14 @@
2
2
  import readline from 'node:readline/promises';
3
3
  import { stdin as input, stdout as output } from 'node:process';
4
4
  import { GpsApiClient } from './clients/gps-api.js';
5
- import { DEFAULT_ENV_PATH, loadGpsConfig, normalizeApiBase, writeGpsEnvFile, } from './config/env.js';
5
+ import { DEFAULT_ENV_PATH, loadGpsConfig, resolveDefaultApiBase, writeGpsEnvFile, } from './config/env.js';
6
6
  import { startStdioServer, startHttpFromEnv } from './index.js';
7
7
  import { browserLogin } from './cli/browser-login.js';
8
8
  import { runSetup } from './cli/setup.js';
9
9
  import { runDoctor, printDoctor } from './cli/doctor.js';
10
10
  import { MCP_SERVER_KEY, PACKAGE, buildMcpServersConfig, buildOAuthConnectEntry, buildRemoteEntry, buildStdioEntry, buildVsCodeServersConfig, resolveMcpPublicUrl, } from './lib/host-config.js';
11
11
  function printConfig(envPath) {
12
- const apiBase = normalizeApiBase(process.env.GPS_API_BASE ?? 'http://localhost:8080');
12
+ const apiBase = resolveDefaultApiBase();
13
13
  const mcpPublic = resolveMcpPublicUrl(apiBase);
14
14
  const stdio = buildStdioEntry(envPath);
15
15
  const remote = buildRemoteEntry(mcpPublic);
@@ -17,7 +17,7 @@ function printConfig(envPath) {
17
17
  console.log(`
18
18
  ═══ Easiest: one command (all major hosts) ═══
19
19
  npx ${PACKAGE} setup
20
- npx ${PACKAGE} setup --mode=oauthOAuth Connect (URL-only)
20
+ npx ${PACKAGE} setup --mode=stdiolocal dev / Claude Desktop stdio-only
21
21
 
22
22
  ═══ Cursor / Claude Desktop (mcpServers) ═══
23
23
  ${JSON.stringify(buildMcpServersConfig(stdio), null, 2)}
@@ -46,7 +46,7 @@ async function promptHidden(question) {
46
46
  }
47
47
  }
48
48
  async function runLogin(refresh, useBrowser) {
49
- const apiBase = normalizeApiBase(process.env.GPS_API_BASE ?? 'http://localhost:8080');
49
+ const apiBase = resolveDefaultApiBase();
50
50
  if (useBrowser) {
51
51
  const result = await browserLogin(apiBase);
52
52
  console.log(`\n✓ Credentials saved to ${DEFAULT_ENV_PATH}`);
@@ -109,8 +109,8 @@ async function main() {
109
109
  const cmd = args[0];
110
110
  if (!cmd || cmd === 'help' || args.includes('--help')) {
111
111
  console.log(`Usage:
112
- npx ${PACKAGE} setup ← easiest: all hosts (Cursor, VS Code, Claude Desktop)
113
- npx ${PACKAGE} setup --mode=oauthCursor Connect (URL-only, no envFile)
112
+ npx ${PACKAGE} setup ← production OAuth Connect (https://lp.gpsglobal.ai/mcp)
113
+ npx ${PACKAGE} setup --mode=stdiolocal dev (GPS_API_BASE=http://localhost:8080)
114
114
  npx ${PACKAGE} doctor ← verify credentials + backend
115
115
  npx ${PACKAGE} login [--browser] [--refresh]
116
116
  npx ${PACKAGE} status
@@ -128,7 +128,7 @@ async function main() {
128
128
  await runSetup({
129
129
  host: host ?? 'all',
130
130
  useBrowser: !args.includes('--no-browser'),
131
- mode: mode ?? 'stdio',
131
+ mode: mode ?? 'oauth',
132
132
  });
133
133
  return;
134
134
  }
@@ -1,4 +1,10 @@
1
1
  export declare const DEFAULT_ENV_PATH: string;
2
+ /** Production LP Workspace origin — default for published npm package. */
3
+ export declare const DEFAULT_GPS_API_BASE = "https://lp.gpsglobal.ai";
4
+ /** Local Docker backend — opt-in via GPS_API_BASE for developers only. */
5
+ export declare const LOCAL_GPS_API_BASE = "http://localhost:8080";
6
+ /** Resolve API base: explicit env → production (not localhost). */
7
+ export declare function resolveDefaultApiBase(): string;
2
8
  export interface GpsEnvConfig {
3
9
  apiBase: string;
4
10
  accessToken: string;
@@ -2,6 +2,14 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  export const DEFAULT_ENV_PATH = path.join(os.homedir(), '.gps', 'mcp.env');
5
+ /** Production LP Workspace origin — default for published npm package. */
6
+ export const DEFAULT_GPS_API_BASE = 'https://lp.gpsglobal.ai';
7
+ /** Local Docker backend — opt-in via GPS_API_BASE for developers only. */
8
+ export const LOCAL_GPS_API_BASE = 'http://localhost:8080';
9
+ /** Resolve API base: explicit env → production (not localhost). */
10
+ export function resolveDefaultApiBase() {
11
+ return normalizeApiBase(process.env.GPS_API_BASE ?? DEFAULT_GPS_API_BASE);
12
+ }
5
13
  export function parseEnvFile(content) {
6
14
  const out = {};
7
15
  for (const line of content.split('\n')) {
@@ -36,7 +44,7 @@ export function loadGpsConfig(envPath = process.env.GPS_MCP_ENV_PATH ?? DEFAULT_
36
44
  }
37
45
  const parsed = parseEnvFile(fs.readFileSync(envPath, 'utf8'));
38
46
  const merged = {
39
- apiBase: normalizeApiBase(parsed.GPS_API_BASE ?? 'http://localhost:8080'),
47
+ apiBase: normalizeApiBase(parsed.GPS_API_BASE ?? DEFAULT_GPS_API_BASE),
40
48
  accessToken: parsed.GPS_ACCESS_TOKEN ?? '',
41
49
  lpId: parsed.GPS_LP_ID ?? '',
42
50
  role: parsed.GPS_ROLE ?? 'lp',
package/dist/http.d.ts CHANGED
@@ -3,6 +3,9 @@ export interface HttpServerOptions {
3
3
  host: string;
4
4
  apiBase: string;
5
5
  publicBaseUrl: string;
6
+ /** Public GPS API origin for OAuth AS in PRM (defaults to publicBaseUrl). */
7
+ publicApiBase?: string;
6
8
  enabled: boolean;
9
+ version?: string;
7
10
  }
8
11
  export declare function startHttpServer(opts: HttpServerOptions): Promise<void>;
package/dist/http.js CHANGED
@@ -5,6 +5,7 @@ import { assertMcpEligibleRole } from './config/env.js';
5
5
  import { createGpsMcpServer } from './server/factory.js';
6
6
  import { decodeGpsJwt } from './lib/jwt.js';
7
7
  import { assertMcpHttpToken } from './lib/token-policy.js';
8
+ import { oauthAuthorizationServerUrl, resolvePublicApiBase } from './lib/public-api-base.js';
8
9
  function parseBearer(req) {
9
10
  const h = req.headers.authorization;
10
11
  if (!h?.startsWith('Bearer '))
@@ -28,15 +29,21 @@ function sendUnauthorized(res, resourceMetadataUrl, message) {
28
29
  export async function startHttpServer(opts) {
29
30
  const app = createMcpExpressApp({ host: opts.host });
30
31
  const resourceMetadataUrl = `${opts.publicBaseUrl}/.well-known/oauth-protected-resource`;
31
- const authServer = `${opts.apiBase}/api/v2/oauth/mcp-cli`;
32
+ const publicApiBase = resolvePublicApiBase(opts.publicApiBase, opts.publicBaseUrl);
33
+ const authServer = oauthAuthorizationServerUrl(publicApiBase);
34
+ const serviceVersion = opts.version ?? process.env.GPS_MCP_VERSION ?? '1.4.4';
32
35
  app.get('/health', (_req, res) => {
33
36
  if (!opts.enabled) {
34
37
  res.status(503).json({ status: 'disabled', message: 'GPS MCP HTTP is disabled' });
35
38
  return;
36
39
  }
37
- res.json({ status: 'ok', service: 'gpsglobal', version: '1.4.0' });
40
+ res.json({ status: 'ok', service: 'gpsglobal', version: serviceVersion });
38
41
  });
39
42
  app.get('/.well-known/oauth-protected-resource', (_req, res) => {
43
+ if (!opts.enabled) {
44
+ res.status(503).json({ error: 'GPS MCP is temporarily disabled' });
45
+ return;
46
+ }
40
47
  res.json({
41
48
  resource: `${opts.publicBaseUrl}/mcp`,
42
49
  authorization_servers: [authServer],
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
2
2
  import { GpsApiClient } from './clients/gps-api.js';
3
- import { loadGpsConfig, normalizeApiBase } from './config/env.js';
3
+ import { loadGpsConfig, normalizeApiBase, LOCAL_GPS_API_BASE } from './config/env.js';
4
4
  import { createGpsMcpServer } from './server/factory.js';
5
5
  import { startHttpServer } from './http.js';
6
6
  export async function startStdioServer() {
@@ -13,8 +13,17 @@ export async function startStdioServer() {
13
13
  export async function startHttpFromEnv() {
14
14
  const port = Number(process.env.PORT ?? 3100);
15
15
  const host = process.env.MCP_HOST ?? '0.0.0.0';
16
- const apiBase = normalizeApiBase(process.env.GPS_API_BASE ?? 'http://localhost:8080');
16
+ const apiBase = normalizeApiBase(process.env.GPS_API_BASE ?? LOCAL_GPS_API_BASE);
17
17
  const publicBaseUrl = normalizeApiBase(process.env.GPS_MCP_PUBLIC_URL ?? `http://localhost:${port}`);
18
+ const publicApiBase = normalizeApiBase(process.env.GPS_MCP_PUBLIC_API_BASE ?? publicBaseUrl);
18
19
  const enabled = process.env.GPS_MCP_ENABLED !== 'false';
19
- await startHttpServer({ port, host, apiBase, publicBaseUrl, enabled });
20
+ await startHttpServer({
21
+ port,
22
+ host,
23
+ apiBase,
24
+ publicBaseUrl,
25
+ publicApiBase,
26
+ enabled,
27
+ version: process.env.GPS_MCP_VERSION,
28
+ });
20
29
  }
@@ -40,7 +40,7 @@ export declare function mergeCursorConfig(entry: StdioEntry | RemoteEntry, serve
40
40
  export declare function mergeVsCodeConfig(entry: StdioEntry | RemoteEntry, transport?: 'stdio' | 'http'): string;
41
41
  export declare function mergeVsCodeWorkspaceConfig(entry: StdioEntry | RemoteEntry, transport?: 'stdio' | 'http', projectRoot?: string): string;
42
42
  export declare function mergeClaudeDesktopConfig(entry: StdioEntry, serverKey?: string): string;
43
- export declare function resolveMcpPublicUrl(apiBase: string): string;
43
+ export { resolveMcpPublicUrl } from './public-url.js';
44
44
  export declare function claudeCodeAddCommand(mcpPublicUrl: string, mode?: 'oauth' | 'remote'): string;
45
45
  export interface SetupHostResult {
46
46
  host: HostId;
@@ -128,13 +128,7 @@ export function mergeVsCodeWorkspaceConfig(entry, transport = 'stdio', projectRo
128
128
  export function mergeClaudeDesktopConfig(entry, serverKey = MCP_SERVER_KEY) {
129
129
  return mergeJsonConfig(claudeDesktopConfigPath(), 'mcpServers', serverKey, entry);
130
130
  }
131
- export function resolveMcpPublicUrl(apiBase) {
132
- const normalized = apiBase.replace(/\/+$/, '');
133
- if (normalized.includes('localhost') || normalized.includes('127.0.0.1')) {
134
- return process.env.GPS_MCP_PUBLIC_URL ?? 'http://localhost:3100';
135
- }
136
- return process.env.GPS_MCP_PUBLIC_URL ?? 'https://lp.gpsglobal.ai';
137
- }
131
+ export { resolveMcpPublicUrl } from './public-url.js';
138
132
  export function claudeCodeAddCommand(mcpPublicUrl, mode = 'oauth') {
139
133
  const url = `${mcpPublicUrl.replace(/\/+$/, '')}/mcp`;
140
134
  if (mode === 'oauth') {
@@ -172,7 +166,8 @@ export function writeAllHostConfigs(envPath, mode, mcpPublicUrl, projectRoot = p
172
166
  catch {
173
167
  results.push({ host: 'vscode', path: vscodeUserMcpPath(), written: false });
174
168
  }
175
- if (mode === 'stdio') {
169
+ // Claude Desktop has no reliable remote HTTP + OAuth — always stdio + envFile.
170
+ if (mode === 'stdio' || mode === 'oauth') {
176
171
  try {
177
172
  results.push({
178
173
  host: 'claude-desktop',
@@ -0,0 +1,14 @@
1
+ /**
2
+ * NMG lineage token helpers — mirrors ml-service/services/nmg.py LINEAGE_RE.
3
+ * Spec: docs/05-incidents/01-citation-navigation-red-team/06-references/08-wiki-format.md
4
+ */
5
+ /** Match `{P3}`, `{P3, P7}`, `{P3≠P7}` (whitespace tolerant). */
6
+ export declare const LINEAGE_RE: RegExp;
7
+ export interface ParsedLineage {
8
+ raw: string;
9
+ pages: number[];
10
+ conflicting: boolean;
11
+ }
12
+ export declare function parseLineageToken(token: string): ParsedLineage | null;
13
+ export declare function findLineageTokens(text: string): ParsedLineage[];
14
+ export declare function clampPage(page: number, maxPage?: number): number;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * NMG lineage token helpers — mirrors ml-service/services/nmg.py LINEAGE_RE.
3
+ * Spec: docs/05-incidents/01-citation-navigation-red-team/06-references/08-wiki-format.md
4
+ */
5
+ /** Match `{P3}`, `{P3, P7}`, `{P3≠P7}` (whitespace tolerant). */
6
+ export const LINEAGE_RE = /\{\s*P(\d+)((?:\s*[,≠]\s*P\d+)*)\s*\}/g;
7
+ const PAGE_NUM_RE = /P(\d+)/g;
8
+ export function parseLineageToken(token) {
9
+ const m = token.match(/^\{\s*P(\d+)((?:\s*[,≠]\s*P\d+)*)\s*\}$/);
10
+ if (!m)
11
+ return null;
12
+ const inner = m[0];
13
+ const conflicting = inner.includes('≠');
14
+ const pages = [...inner.matchAll(PAGE_NUM_RE)]
15
+ .map((x) => Number(x[1]))
16
+ .filter((n) => Number.isFinite(n) && n > 0);
17
+ return { raw: inner, pages, conflicting };
18
+ }
19
+ export function findLineageTokens(text) {
20
+ const out = [];
21
+ for (const m of text.matchAll(LINEAGE_RE)) {
22
+ const parsed = parseLineageToken(m[0]);
23
+ if (parsed)
24
+ out.push(parsed);
25
+ }
26
+ return out;
27
+ }
28
+ export function clampPage(page, maxPage) {
29
+ if (!Number.isFinite(page) || page < 1)
30
+ return 1;
31
+ if (maxPage != null && maxPage > 0)
32
+ return Math.min(page, maxPage);
33
+ return page;
34
+ }
@@ -0,0 +1,3 @@
1
+ /** Public GPS API origin for OAuth PRM (external clients). Internal GPS_API_BASE may differ in ECS. */
2
+ export declare function resolvePublicApiBase(publicApiBase: string | undefined, publicMcpUrl: string): string;
3
+ export declare function oauthAuthorizationServerUrl(publicApiBase: string): string;
@@ -0,0 +1,8 @@
1
+ /** Public GPS API origin for OAuth PRM (external clients). Internal GPS_API_BASE may differ in ECS. */
2
+ export function resolvePublicApiBase(publicApiBase, publicMcpUrl) {
3
+ const base = (publicApiBase ?? publicMcpUrl).replace(/\/+$/, '');
4
+ return base;
5
+ }
6
+ export function oauthAuthorizationServerUrl(publicApiBase) {
7
+ return `${publicApiBase.replace(/\/+$/, '')}/api/v2/oauth/mcp-cli`;
8
+ }
@@ -0,0 +1,14 @@
1
+ /** LP Workspace deep-link path (FundDetailPage + Event Browser artifacts). */
2
+ export declare const FUND_ANALYZE_DEEP_LINK_PATTERN = "/lp/{lpId}/fund/{fundId}?tab=analyze#page={N}";
3
+ export interface WikiLinkedContext {
4
+ lpId: string;
5
+ fundId: string;
6
+ /** Public LP Workspace origin, e.g. https://lp.gpsglobal.ai or http://localhost:5173 */
7
+ workspacePublicUrl: string;
8
+ }
9
+ export declare function isLocalHostOrigin(url: string): boolean;
10
+ /** Public MCP Streamable HTTP origin (e.g. https://lp.gpsglobal.ai or http://localhost:3100). */
11
+ export declare function resolveMcpPublicUrl(apiBase: string): string;
12
+ /** Public LP Workspace PWA origin for fund PDF page links (e.g. https://lp.gpsglobal.ai or :5173). */
13
+ export declare function resolveWorkspacePublicUrl(apiBase: string): string;
14
+ export declare function buildFundAnalyzePageUrl(workspacePublicUrl: string, lpId: string, fundId: string, page: number): string;
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Public origin resolution for MCP clients — single place for prod vs local URLs.
3
+ * MCP HTTP (/mcp), LP Workspace PWA (/lp/…), and API base may differ in local Docker.
4
+ */
5
+ import { DEFAULT_GPS_API_BASE, normalizeApiBase } from '../config/env.js';
6
+ /** LP Workspace deep-link path (FundDetailPage + Event Browser artifacts). */
7
+ export const FUND_ANALYZE_DEEP_LINK_PATTERN = '/lp/{lpId}/fund/{fundId}?tab=analyze#page={N}';
8
+ export function isLocalHostOrigin(url) {
9
+ const normalized = normalizeApiBase(url);
10
+ return normalized.includes('localhost') || normalized.includes('127.0.0.1');
11
+ }
12
+ /** Public MCP Streamable HTTP origin (e.g. https://lp.gpsglobal.ai or http://localhost:3100). */
13
+ export function resolveMcpPublicUrl(apiBase) {
14
+ if (isLocalHostOrigin(apiBase)) {
15
+ return process.env.GPS_MCP_PUBLIC_URL ?? 'http://localhost:3100';
16
+ }
17
+ const normalized = normalizeApiBase(apiBase);
18
+ return process.env.GPS_MCP_PUBLIC_URL ?? (normalized || DEFAULT_GPS_API_BASE);
19
+ }
20
+ /** Public LP Workspace PWA origin for fund PDF page links (e.g. https://lp.gpsglobal.ai or :5173). */
21
+ export function resolveWorkspacePublicUrl(apiBase) {
22
+ if (isLocalHostOrigin(apiBase)) {
23
+ return process.env.GPS_WORKSPACE_PUBLIC_URL ?? 'http://localhost:5173';
24
+ }
25
+ return process.env.GPS_MCP_PUBLIC_URL ?? DEFAULT_GPS_API_BASE;
26
+ }
27
+ export function buildFundAnalyzePageUrl(workspacePublicUrl, lpId, fundId, page) {
28
+ const base = normalizeApiBase(workspacePublicUrl);
29
+ return `${base}/lp/${encodeURIComponent(lpId)}/fund/${encodeURIComponent(fundId)}?tab=analyze#page=${page}`;
30
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Transform NMG wiki `{Pn}` lineage tokens into LP Workspace deep links.
3
+ *
4
+ * Deep-link convention: see public-url.ts / docs/57-mcp/020-wiki-linked-citations.md
5
+ * Canonical wiki.md on disk stays unchanged — transform at MCP read time only.
6
+ */
7
+ import { type WikiLinkedContext } from './public-url.js';
8
+ export type { WikiLinkedContext } from './public-url.js';
9
+ export type WikiServeFormat = 'linked' | 'raw' | 'summary';
10
+ export interface WikiLinkedMeta {
11
+ format: 'linked';
12
+ divider_pages: number[];
13
+ extracted_pages?: number;
14
+ lineage_tokens_transformed: number;
15
+ links_emitted: number;
16
+ }
17
+ export interface SplitWiki {
18
+ frontMatterBlock: string;
19
+ body: string;
20
+ dividerPages: number[];
21
+ extractedPages?: number;
22
+ }
23
+ /** Split YAML front matter; parse divider_pages + extracted_pages for edge-case handling. */
24
+ export declare function splitWikiFrontMatter(raw: string): SplitWiki;
25
+ /** Transform `{Pn}` tokens in wiki body to markdown page links. Front matter is preserved verbatim. */
26
+ export declare function transformWikiBodyWithPageLinks(body: string, ctx: WikiLinkedContext, opts?: {
27
+ dividerPages?: number[];
28
+ extractedPages?: number;
29
+ }): {
30
+ text: string;
31
+ tokensTransformed: number;
32
+ linksEmitted: number;
33
+ };
34
+ export declare function transformWikiWithPageLinks(rawWiki: string, ctx: WikiLinkedContext): {
35
+ content: string;
36
+ meta: WikiLinkedMeta;
37
+ };
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Transform NMG wiki `{Pn}` lineage tokens into LP Workspace deep links.
3
+ *
4
+ * Deep-link convention: see public-url.ts / docs/57-mcp/020-wiki-linked-citations.md
5
+ * Canonical wiki.md on disk stays unchanged — transform at MCP read time only.
6
+ */
7
+ import { buildFundAnalyzePageUrl, } from './public-url.js';
8
+ import { clampPage, LINEAGE_RE, parseLineageToken } from './nmg-lineage.js';
9
+ /** Split YAML front matter; parse divider_pages + extracted_pages for edge-case handling. */
10
+ export function splitWikiFrontMatter(raw) {
11
+ const fm = raw.match(/^---\n([\s\S]*?)\n---\n?/);
12
+ if (!fm) {
13
+ return { frontMatterBlock: '', body: raw, dividerPages: [], extractedPages: undefined };
14
+ }
15
+ const yaml = fm[1];
16
+ const dividerPages = parseYamlIntList(yaml, 'divider_pages');
17
+ const extractedPages = parseYamlInt(yaml, 'extracted_pages');
18
+ return {
19
+ frontMatterBlock: fm[0],
20
+ body: raw.slice(fm[0].length),
21
+ dividerPages,
22
+ extractedPages,
23
+ };
24
+ }
25
+ function parseYamlIntList(yaml, key) {
26
+ const m = yaml.match(new RegExp(`^${key}:\\s*\\[([^\\]]*)\\]`, 'm'));
27
+ if (!m)
28
+ return [];
29
+ return m[1]
30
+ .split(',')
31
+ .map((s) => parseInt(s.trim(), 10))
32
+ .filter((n) => Number.isFinite(n) && n > 0);
33
+ }
34
+ function parseYamlInt(yaml, key) {
35
+ const m = yaml.match(new RegExp(`^${key}:\\s*(\\d+)`, 'm'));
36
+ if (!m)
37
+ return undefined;
38
+ const n = parseInt(m[1], 10);
39
+ return Number.isFinite(n) && n > 0 ? n : undefined;
40
+ }
41
+ function renderPageLink(page, ctx, opts) {
42
+ if (opts.dividerPages.includes(page)) {
43
+ return `[Page ${page} (divider — no document content)](#gps-divider-page-${page})`;
44
+ }
45
+ const clamped = clampPage(page, opts.maxPage);
46
+ const label = clamped !== page ? `Page ${page}→${clamped}` : `Page ${clamped}`;
47
+ return `[${label}](${buildFundAnalyzePageUrl(ctx.workspacePublicUrl, ctx.lpId, ctx.fundId, clamped)})`;
48
+ }
49
+ function renderLineageLinks(parsed, ctx, opts) {
50
+ if (parsed.pages.length === 0)
51
+ return parsed.raw;
52
+ if (parsed.conflicting) {
53
+ return parsed.pages.map((p) => renderPageLink(p, ctx, opts)).join(' **vs** ');
54
+ }
55
+ if (parsed.pages.length === 1) {
56
+ return renderPageLink(parsed.pages[0], ctx, opts);
57
+ }
58
+ return parsed.pages.map((p) => renderPageLink(p, ctx, opts)).join(', ');
59
+ }
60
+ /** Transform `{Pn}` tokens in wiki body to markdown page links. Front matter is preserved verbatim. */
61
+ export function transformWikiBodyWithPageLinks(body, ctx, opts = {}) {
62
+ const linkOpts = {
63
+ dividerPages: opts.dividerPages ?? [],
64
+ maxPage: opts.extractedPages,
65
+ };
66
+ let tokensTransformed = 0;
67
+ let linksEmitted = 0;
68
+ const text = body.replace(LINEAGE_RE, (match) => {
69
+ const parsed = parseLineageToken(match);
70
+ if (!parsed)
71
+ return match;
72
+ tokensTransformed += 1;
73
+ linksEmitted += parsed.pages.filter((p) => !linkOpts.dividerPages.includes(p)).length;
74
+ return renderLineageLinks(parsed, ctx, linkOpts);
75
+ });
76
+ return { text, tokensTransformed, linksEmitted };
77
+ }
78
+ export function transformWikiWithPageLinks(rawWiki, ctx) {
79
+ const split = splitWikiFrontMatter(rawWiki);
80
+ const { text, tokensTransformed, linksEmitted } = transformWikiBodyWithPageLinks(split.body, ctx, { dividerPages: split.dividerPages, extractedPages: split.extractedPages });
81
+ const preamble = [
82
+ '> **GPS linked wiki** — citation links open **Analyze → PDF** at the cited page in LP Workspace.',
83
+ '> Requires GPS sign-in. Canonical NMG tokens `{Pn}` on disk; request `format=raw` for byte-identical wiki.md.',
84
+ '',
85
+ ].join('\n');
86
+ return {
87
+ content: split.frontMatterBlock + preamble + text,
88
+ meta: {
89
+ format: 'linked',
90
+ divider_pages: split.dividerPages,
91
+ extracted_pages: split.extractedPages,
92
+ lineage_tokens_transformed: tokensTransformed,
93
+ links_emitted: linksEmitted,
94
+ },
95
+ };
96
+ }
@@ -0,0 +1,17 @@
1
+ import { type WikiLinkedMeta, type WikiServeFormat } from './wiki-linked.js';
2
+ export interface ServeFundWikiInput {
3
+ content: string;
4
+ fundId: string;
5
+ lpId: string;
6
+ fundName: string;
7
+ wikiStatus: string;
8
+ format?: WikiServeFormat;
9
+ }
10
+ export interface ServeFundWikiResult {
11
+ body: string;
12
+ format: WikiServeFormat;
13
+ linkedMeta?: WikiLinkedMeta;
14
+ }
15
+ /** Transform or pass through wiki markdown per format. */
16
+ export declare function serveFundWiki(input: ServeFundWikiInput): ServeFundWikiResult;
17
+ export declare function buildFundWikiStructuredContent(input: ServeFundWikiInput, served: ServeFundWikiResult): Record<string, unknown>;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Serve fund wiki content in the requested format — SRP between tool registration and transform.
3
+ */
4
+ import { resolveDefaultApiBase } from '../config/env.js';
5
+ import { FUND_ANALYZE_DEEP_LINK_PATTERN, resolveWorkspacePublicUrl } from './public-url.js';
6
+ import { splitWikiFrontMatter, transformWikiWithPageLinks, } from './wiki-linked.js';
7
+ function wikiSummary(content) {
8
+ const { frontMatterBlock, body } = splitWikiFrontMatter(content);
9
+ const titles = body.split('\n').filter((l) => l.startsWith('#')).slice(0, 20);
10
+ if (!frontMatterBlock)
11
+ return titles.join('\n').trim();
12
+ return `${frontMatterBlock}${titles.join('\n')}`.trim();
13
+ }
14
+ function extractSchemaVersion(content) {
15
+ const m = content.match(/schema_version:\s*["']?([^"'\n]+)/);
16
+ return m?.[1]?.trim();
17
+ }
18
+ /** Transform or pass through wiki markdown per format. */
19
+ export function serveFundWiki(input) {
20
+ const format = input.format ?? 'linked';
21
+ if (format === 'summary') {
22
+ return { body: wikiSummary(input.content), format };
23
+ }
24
+ if (format === 'raw') {
25
+ return { body: input.content, format };
26
+ }
27
+ const transformed = transformWikiWithPageLinks(input.content, {
28
+ lpId: input.lpId,
29
+ fundId: input.fundId,
30
+ workspacePublicUrl: resolveWorkspacePublicUrl(resolveDefaultApiBase()),
31
+ });
32
+ return { body: transformed.content, format, linkedMeta: transformed.meta };
33
+ }
34
+ export function buildFundWikiStructuredContent(input, served) {
35
+ return {
36
+ fund_id: input.fundId,
37
+ fund_name: input.fundName,
38
+ has_wiki: true,
39
+ wiki_status: input.wikiStatus,
40
+ format: served.format,
41
+ schema_version: extractSchemaVersion(input.content),
42
+ byte_length_raw: input.content.length,
43
+ byte_length: served.body.length,
44
+ pdf_deep_link_pattern: FUND_ANALYZE_DEEP_LINK_PATTERN,
45
+ ...(served.linkedMeta ?? {}),
46
+ };
47
+ }
@@ -1,11 +1,16 @@
1
1
  import { z } from 'zod';
2
2
  import { assertMcpEligibleRole } from '../config/env.js';
3
+ import { buildFundWikiStructuredContent, serveFundWiki } from '../lib/wiki-serve.js';
3
4
  const listFundsSchema = {
4
5
  include_closed: z.boolean().optional().describe('If false (default), omit funds whose closing_date is in the past.'),
5
6
  };
6
7
  const getFundWikiSchema = {
7
8
  fund_id: z.string().describe('Fund identifier from list_funds (e.g. fund_1776212451730_0).'),
8
- format: z.enum(['markdown', 'summary']).optional().describe('markdown = full wiki; summary = YAML header + section titles only.'),
9
+ format: z
10
+ .enum(['linked', 'raw', 'summary'])
11
+ .optional()
12
+ .describe('linked (default) = {Pn} → clickable LP Workspace PDF page links; '
13
+ + 'raw = byte-identical NMG wiki.md from vault; summary = YAML header + section titles only.'),
9
14
  };
10
15
  function isClosed(closingDate) {
11
16
  if (!closingDate)
@@ -32,28 +37,6 @@ function mapFund(f) {
32
37
  fund_summary: f.fundSummary ?? '',
33
38
  };
34
39
  }
35
- function wikiSummary(content) {
36
- const lines = content.split('\n');
37
- const header = [];
38
- let inYaml = false;
39
- for (const line of lines) {
40
- if (line.trim() === '---') {
41
- inYaml = !inYaml;
42
- header.push(line);
43
- if (!inYaml && header.length > 1)
44
- break;
45
- continue;
46
- }
47
- if (inYaml)
48
- header.push(line);
49
- }
50
- const titles = lines.filter((l) => l.startsWith('#')).slice(0, 20);
51
- return [...header, '', ...titles].join('\n').trim();
52
- }
53
- function extractSchemaVersion(content) {
54
- const m = content.match(/schema_version:\s*["']?([^"'\n]+)/);
55
- return m?.[1]?.trim();
56
- }
57
40
  export function registerGpsTools(server, client) {
58
41
  server.registerTool('list_funds', {
59
42
  description: 'List all funds in the authenticated LP vault with metadata and has_wiki flags.',
@@ -74,7 +57,10 @@ export function registerGpsTools(server, client) {
74
57
  };
75
58
  });
76
59
  server.registerTool('get_fund_wiki', {
77
- description: 'Retrieve NMG wiki markdown for one fund in the authenticated LP vault.',
60
+ description: 'Retrieve NMG fund wiki for the authenticated LP vault. '
61
+ + 'Default format=linked transforms {P18} lineage tokens into LP Workspace deep links '
62
+ + '(?tab=analyze#page=N) so hosts can cite "Page 18" with a clickable PDF anchor. '
63
+ + 'Use format=raw for canonical on-disk wiki.md (byte parity with API).',
78
64
  inputSchema: getFundWikiSchema,
79
65
  }, async ({ fund_id, format }) => {
80
66
  assertMcpEligibleRole(client.config.role);
@@ -92,17 +78,18 @@ export function registerGpsTools(server, client) {
92
78
  isError: true,
93
79
  };
94
80
  }
95
- const body = format === 'summary' ? wikiSummary(wiki.content) : wiki.content;
81
+ const serveInput = {
82
+ content: wiki.content,
83
+ fundId: fund_id,
84
+ lpId: client.config.lpId,
85
+ fundName: wiki.fund_name,
86
+ wikiStatus: wiki.wiki_status,
87
+ format,
88
+ };
89
+ const served = serveFundWiki(serveInput);
96
90
  return {
97
- content: [{ type: 'text', text: body }],
98
- structuredContent: {
99
- fund_id: wiki.fund_id,
100
- fund_name: wiki.fund_name,
101
- has_wiki: true,
102
- wiki_status: wiki.wiki_status,
103
- schema_version: extractSchemaVersion(wiki.content),
104
- byte_length: wiki.content.length,
105
- },
91
+ content: [{ type: 'text', text: served.body }],
92
+ structuredContent: buildFundWikiStructuredContent(serveInput, served),
106
93
  };
107
94
  });
108
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gpsglobal-ai/gpsglobal",
3
- "version": "1.4.4",
3
+ "version": "1.4.6",
4
4
  "description": "GPS LP fund wiki MCP server — list_funds and get_fund_wiki for Cursor, Copilot, Claude",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",