pulumi-package-docs 0.8.0 → 0.9.1
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 +98 -4
- package/bin/args.js +62 -3
- package/bin/args.test.js +60 -0
- package/bin/cli-bun.js +55 -23
- package/bin/cli.js +74 -28
- package/bin/cli.test.js +18 -0
- package/bin/static.js +196 -0
- package/bin/static.test.js +176 -0
- package/dist/client/assets/MemberDetail-Du4HYTda.js +1 -0
- package/dist/client/assets/{token-oQmZfAP3.js → examples-zkHFEaMt.js} +1 -1
- package/dist/client/assets/index-g58gbZKl.js +12 -0
- package/dist/client/assets/matchContext-BfDTxnb0.js +2 -0
- package/dist/client/assets/providers._name-DztqmroK.js +1 -0
- package/dist/client/assets/providers._name.functions._token-Dds2hD5R.js +1 -0
- package/dist/client/assets/providers._name.functions._token-DkZe4R_v.js +1 -0
- package/dist/client/assets/providers._name.index-B6cK3ZCZ.js +1 -0
- package/dist/client/assets/providers._name.index-CvFTUtAs.js +1 -0
- package/dist/client/assets/providers._name.resources._token-Bf7TPYcB.js +1 -0
- package/dist/client/assets/providers._name.resources._token-CIjv9yb8.js +1 -0
- package/dist/client/assets/routes-Dz3Kksbm.js +1 -0
- package/dist/mcp/stdio.js +449 -0
- package/dist/server/assets/{MemberDetail-C5sAHNZW.js → MemberDetail-CdHUSmQD.js} +2 -1
- package/dist/server/assets/{_tanstack-start-manifest_v-B0J-S-em.js → _tanstack-start-manifest_v-Drm3Ru6D.js} +15 -15
- package/dist/server/assets/api-D49ZDhMh.js +27 -0
- package/dist/server/assets/{providers._name.functions._token-Gkw4zt3M.js → providers._name.functions._token-BvkwmQ-L.js} +3 -2
- package/dist/server/assets/{providers._name.index-CEXkl-76.js → providers._name.index-DBkfEjsy.js} +3 -2
- package/dist/server/assets/{providers._name.resources._token-R_i2MmRl.js → providers._name.resources._token-Sl31Qlvd.js} +3 -2
- package/dist/server/assets/{schema.server-B-VvU1qb.js → providers.server-B1ZHZ5jE.js} +48 -1
- package/dist/server/assets/{router-1INob9fh.js → router-_zs8P-B2.js} +24 -87
- package/dist/server/assets/{routes-4jiXnS1p.js → routes-BUxagg9r.js} +1 -1
- package/dist/server/assets/token-DBEFeZIN.js +92 -0
- package/dist/server/server.js +987 -375
- package/examples/github-pages.yml +56 -0
- package/examples/gitlab-ci.yml +22 -0
- package/package.json +7 -3
- package/src/components/ThemeToggle.tsx +37 -0
- package/src/lib/pulumi/api.ts +18 -69
- package/src/lib/pulumi/mcp.server.test.ts +112 -0
- package/src/lib/pulumi/mcp.server.ts +11 -6
- package/src/lib/pulumi/providers.server.ts +80 -0
- package/src/lib/pulumi/stdio.server.ts +16 -0
- package/src/lib/pulumi/token.test.ts +44 -1
- package/src/lib/pulumi/token.ts +28 -0
- package/src/lib/static-docs.ts +15 -0
- package/src/router.tsx +3 -0
- package/src/routes/__root.tsx +6 -3
- package/src/routes/providers.$name.functions.$token.tsx +7 -4
- package/src/routes/providers.$name.index.tsx +2 -2
- package/src/routes/providers.$name.resources.$token.tsx +7 -4
- package/vite.config.ts +41 -1
- package/dist/client/assets/MemberDetail-BS9DbqEt.js +0 -1
- package/dist/client/assets/index-2FzxAhzN.js +0 -13
- package/dist/client/assets/providers._name-FmJEVkdV.js +0 -1
- package/dist/client/assets/providers._name.functions._token-BzjmNHea.js +0 -1
- package/dist/client/assets/providers._name.functions._token-D768U6I-.js +0 -1
- package/dist/client/assets/providers._name.index-C42yQ95z.js +0 -1
- package/dist/client/assets/providers._name.index-Dm0YiZWP.js +0 -1
- package/dist/client/assets/providers._name.resources._token-CIYPoqyA.js +0 -1
- package/dist/client/assets/providers._name.resources._token-D0OLvgo_.js +0 -1
- package/dist/client/assets/routes-DKNWlgPZ.js +0 -1
- package/dist/client/assets/useSelector-BIt42tGI.js +0 -1
- package/dist/server/assets/api-C55bS4R6.js +0 -64
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Publishes the local provider docs for this Pulumi project to GitHub Pages.
|
|
2
|
+
# Copy to .github/workflows/docs.yml in your Pulumi project's repo, then enable
|
|
3
|
+
# Pages for the repo with "Source: GitHub Actions" (Settings -> Pages).
|
|
4
|
+
name: Docs
|
|
5
|
+
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
branches: [main]
|
|
9
|
+
workflow_dispatch:
|
|
10
|
+
|
|
11
|
+
permissions:
|
|
12
|
+
contents: read
|
|
13
|
+
pages: write
|
|
14
|
+
id-token: write
|
|
15
|
+
|
|
16
|
+
# One deployment at a time; don't cancel one that's already publishing.
|
|
17
|
+
concurrency:
|
|
18
|
+
group: pages
|
|
19
|
+
cancel-in-progress: false
|
|
20
|
+
|
|
21
|
+
jobs:
|
|
22
|
+
build:
|
|
23
|
+
runs-on: ubuntu-latest
|
|
24
|
+
steps:
|
|
25
|
+
- uses: actions/checkout@v7
|
|
26
|
+
|
|
27
|
+
- uses: actions/setup-node@v7
|
|
28
|
+
with:
|
|
29
|
+
node-version: 24
|
|
30
|
+
|
|
31
|
+
# Puts the `pulumi` CLI on PATH; the docs are generated from the schemas
|
|
32
|
+
# it fetches for each package in Pulumi.yaml.
|
|
33
|
+
- uses: pulumi/actions@v7
|
|
34
|
+
|
|
35
|
+
# A project site is served from https://<owner>.github.io/<repo>/, so the
|
|
36
|
+
# site is built for that path. For a user/org site (<owner>.github.io),
|
|
37
|
+
# drop --base.
|
|
38
|
+
- name: Generate docs
|
|
39
|
+
run: npx -y pulumi-package-docs static --out ./site --base "/${GITHUB_REPOSITORY#*/}/"
|
|
40
|
+
|
|
41
|
+
- uses: actions/upload-pages-artifact@v5
|
|
42
|
+
with:
|
|
43
|
+
path: ./site
|
|
44
|
+
# The generated site includes a `.nojekyll` file, and this action
|
|
45
|
+
# drops dotfiles unless asked not to.
|
|
46
|
+
include-hidden-files: true
|
|
47
|
+
|
|
48
|
+
deploy:
|
|
49
|
+
needs: build
|
|
50
|
+
runs-on: ubuntu-latest
|
|
51
|
+
environment:
|
|
52
|
+
name: github-pages
|
|
53
|
+
url: ${{ steps.deployment.outputs.page_url }}
|
|
54
|
+
steps:
|
|
55
|
+
- id: deployment
|
|
56
|
+
uses: actions/deploy-pages@v5
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Publishes the local provider docs for this Pulumi project to GitLab Pages.
|
|
2
|
+
# Copy this job into your Pulumi project's .gitlab-ci.yml.
|
|
3
|
+
pages:
|
|
4
|
+
image: node:24
|
|
5
|
+
stage: deploy
|
|
6
|
+
before_script:
|
|
7
|
+
# Puts the `pulumi` CLI on PATH; the docs are generated from the schemas
|
|
8
|
+
# it fetches for each package in Pulumi.yaml.
|
|
9
|
+
- curl -fsSL https://get.pulumi.com | sh
|
|
10
|
+
- export PATH="$PATH:$HOME/.pulumi/bin"
|
|
11
|
+
script:
|
|
12
|
+
# CI_PAGES_URL is where this site will be served from — its path is what
|
|
13
|
+
# the docs have to be built for ("/" with unique domains enabled, "/<project>/"
|
|
14
|
+
# otherwise).
|
|
15
|
+
- export DOCS_BASE="$(node -e 'console.log(new URL(process.env.CI_PAGES_URL).pathname)')"
|
|
16
|
+
- npx -y pulumi-package-docs static --out public --base "$DOCS_BASE"
|
|
17
|
+
artifacts:
|
|
18
|
+
paths:
|
|
19
|
+
# GitLab Pages publishes this directory, and only under this name.
|
|
20
|
+
- public
|
|
21
|
+
rules:
|
|
22
|
+
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pulumi-package-docs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Local documentation viewer for the parameterized/local Pulumi providers used by a Pulumi project.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"files": [
|
|
19
19
|
"bin",
|
|
20
20
|
"dist",
|
|
21
|
+
"examples",
|
|
21
22
|
"src",
|
|
22
23
|
"tsconfig.json",
|
|
23
24
|
"vite.config.ts"
|
|
@@ -28,11 +29,14 @@
|
|
|
28
29
|
"scripts": {
|
|
29
30
|
"dev": "vite dev --port 3000",
|
|
30
31
|
"generate-routes": "tsr generate",
|
|
31
|
-
"build": "vite build",
|
|
32
|
+
"build": "vite build && npm run build:mcp",
|
|
33
|
+
"build:mcp": "vite build --config vite.mcp.config.ts",
|
|
32
34
|
"preview": "vite preview",
|
|
33
35
|
"start": "node bin/cli.js",
|
|
36
|
+
"static": "node bin/cli.js static",
|
|
34
37
|
"compile:bun": "node bin/generate-embedded-assets.js && bun build ./bin/cli-bun.js --compile --outfile pulumi-package-docs",
|
|
35
38
|
"test": "vitest run",
|
|
39
|
+
"test:stdio": "node scripts/smoke-test-stdio.js node bin/cli.js --stdio && node scripts/smoke-test-stdio.js ./pulumi-package-docs --stdio",
|
|
36
40
|
"test:watch": "vitest",
|
|
37
41
|
"lint": "oxlint",
|
|
38
42
|
"format": "oxfmt . && oxlint --fix",
|
|
@@ -54,7 +58,7 @@
|
|
|
54
58
|
"@types/node": "^26.6.1",
|
|
55
59
|
"@types/react": "^19.2.0",
|
|
56
60
|
"@types/react-dom": "^19.2.0",
|
|
57
|
-
"oxfmt": "^0.
|
|
61
|
+
"oxfmt": "^0.70.0",
|
|
58
62
|
"oxlint": "^1.83.0",
|
|
59
63
|
"typescript": "^7.0.2",
|
|
60
64
|
"vitest": "^5.0.1"
|
|
@@ -119,3 +119,40 @@ function MonitorIcon() {
|
|
|
119
119
|
</IconSvg>
|
|
120
120
|
)
|
|
121
121
|
}
|
|
122
|
+
|
|
123
|
+
// Wires the static build's theme switch up without hydration: one delegated
|
|
124
|
+
// click listener, using the same `data-theme` + localStorage contract as
|
|
125
|
+
// ThemeToggle above (and as the pre-hydration script in `__root.tsx`).
|
|
126
|
+
const staticThemeScript = `(function(){var g=document.currentScript.previousElementSibling;if(!g)return;
|
|
127
|
+
function apply(m){try{if(m==='system'){delete document.documentElement.dataset.theme;localStorage.removeItem('theme')}else{document.documentElement.dataset.theme=m;localStorage.setItem('theme',m)}}catch(e){if(m==='system'){delete document.documentElement.dataset.theme}else{document.documentElement.dataset.theme=m}}sync(m)}
|
|
128
|
+
function sync(m){var b=g.querySelectorAll('button[data-theme-mode]');for(var i=0;i<b.length;i++){b[i].setAttribute('aria-pressed',String(b[i].getAttribute('data-theme-mode')===m))}}
|
|
129
|
+
g.addEventListener('click',function(e){var b=e.target.closest('button[data-theme-mode]');if(b){apply(b.getAttribute('data-theme-mode'))}});
|
|
130
|
+
var s=null;try{s=localStorage.getItem('theme')}catch(e){}
|
|
131
|
+
sync(s==='light'||s==='dark'?s:'system')})()`
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The theme switch for the static build. Same markup as `ThemeToggle`, but
|
|
135
|
+
* driven by an inline script rather than React, since static pages ship
|
|
136
|
+
* without the hydration bundle.
|
|
137
|
+
*/
|
|
138
|
+
export function StaticThemeToggle() {
|
|
139
|
+
return (
|
|
140
|
+
<>
|
|
141
|
+
<div className="theme-switch" role="group" aria-label="Theme">
|
|
142
|
+
{OPTIONS.map(({ mode, label, Icon }) => (
|
|
143
|
+
<button
|
|
144
|
+
key={mode}
|
|
145
|
+
type="button"
|
|
146
|
+
className="theme-switch-option"
|
|
147
|
+
data-theme-mode={mode}
|
|
148
|
+
aria-pressed={mode === 'system'}
|
|
149
|
+
aria-label={label}
|
|
150
|
+
>
|
|
151
|
+
<Icon />
|
|
152
|
+
</button>
|
|
153
|
+
))}
|
|
154
|
+
</div>
|
|
155
|
+
<script dangerouslySetInnerHTML={{ __html: staticThemeScript }} />
|
|
156
|
+
</>
|
|
157
|
+
)
|
|
158
|
+
}
|
package/src/lib/pulumi/api.ts
CHANGED
|
@@ -1,77 +1,26 @@
|
|
|
1
1
|
import { createServerFn } from '@tanstack/react-start'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export interface ProjectSummary {
|
|
19
|
-
targetDir: string
|
|
20
|
-
project: Pick<PulumiProject, 'root' | 'name' | 'runtime'> | null
|
|
21
|
-
providers: ProviderSummary[]
|
|
22
|
-
}
|
|
3
|
+
import {
|
|
4
|
+
loadProjectSummary,
|
|
5
|
+
loadProviderDetail,
|
|
6
|
+
} from '@/lib/pulumi/providers.server'
|
|
7
|
+
import type {
|
|
8
|
+
ProjectSummary,
|
|
9
|
+
ProviderDetail,
|
|
10
|
+
} from '@/lib/pulumi/providers.server'
|
|
11
|
+
|
|
12
|
+
export type {
|
|
13
|
+
ProjectSummary,
|
|
14
|
+
ProviderDetail,
|
|
15
|
+
ProviderSummary,
|
|
16
|
+
} from '@/lib/pulumi/providers.server'
|
|
23
17
|
|
|
24
18
|
export const listProviders = createServerFn({ method: 'GET' }).handler(
|
|
25
|
-
|
|
26
|
-
const targetDir = getTargetDir()
|
|
27
|
-
const project = loadPulumiProject(targetDir)
|
|
28
|
-
|
|
29
|
-
if (!project) {
|
|
30
|
-
return { targetDir, project: null, providers: [] }
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const entries = await Promise.all(
|
|
34
|
-
project.packages.map((ref) => resolveProviderSchema(project.root, ref)),
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
const providers: ProviderSummary[] = entries.map((entry) => ({
|
|
38
|
-
name: entry.ref.name,
|
|
39
|
-
source: entry.ref.source,
|
|
40
|
-
version: entry.ref.version,
|
|
41
|
-
parameters: entry.ref.parameters,
|
|
42
|
-
resourceCount: Object.keys(entry.schema?.resources ?? {}).length,
|
|
43
|
-
functionCount: Object.keys(entry.schema?.functions ?? {}).length,
|
|
44
|
-
origin: entry.origin,
|
|
45
|
-
error: entry.error,
|
|
46
|
-
}))
|
|
47
|
-
|
|
48
|
-
return {
|
|
49
|
-
targetDir,
|
|
50
|
-
project: {
|
|
51
|
-
root: project.root,
|
|
52
|
-
name: project.name,
|
|
53
|
-
runtime: project.runtime,
|
|
54
|
-
},
|
|
55
|
-
providers,
|
|
56
|
-
}
|
|
57
|
-
},
|
|
19
|
+
(): Promise<ProjectSummary> => loadProjectSummary(),
|
|
58
20
|
)
|
|
59
21
|
|
|
60
|
-
export interface ProviderDetail extends ProviderEntry {
|
|
61
|
-
/** The Pulumi project's runtime (e.g. "nodejs", "python", "go"), used to render Inputs/Outputs names in that language's casing. */
|
|
62
|
-
runtime?: string
|
|
63
|
-
}
|
|
64
|
-
|
|
65
22
|
export const getProvider = createServerFn({ method: 'GET' })
|
|
66
23
|
.validator((name: string) => name)
|
|
67
|
-
.handler(
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
if (!project) return null
|
|
71
|
-
|
|
72
|
-
const ref = project.packages.find((p) => p.name === name)
|
|
73
|
-
if (!ref) return null
|
|
74
|
-
|
|
75
|
-
const entry = await resolveProviderSchema(project.root, ref)
|
|
76
|
-
return { ...entry, runtime: project.runtime }
|
|
77
|
-
})
|
|
24
|
+
.handler(({ data: name }): Promise<ProviderDetail | null> =>
|
|
25
|
+
loadProviderDetail(name),
|
|
26
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
6
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
8
|
+
|
|
9
|
+
import { createMcpServer } from '@/lib/pulumi/mcp.server'
|
|
10
|
+
|
|
11
|
+
let projectDir: string
|
|
12
|
+
const originalEnv = process.env.PULUMI_LOCAL_DOCS_DIR
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
projectDir = mkdtempSync(join(tmpdir(), 'pulumi-package-docs-mcp-test-'))
|
|
16
|
+
writeFileSync(
|
|
17
|
+
join(projectDir, 'Pulumi.yaml'),
|
|
18
|
+
`name: my-project\nruntime: nodejs\n`,
|
|
19
|
+
)
|
|
20
|
+
process.env.PULUMI_LOCAL_DOCS_DIR = projectDir
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
rmSync(projectDir, { recursive: true, force: true })
|
|
25
|
+
if (originalEnv === undefined) delete process.env.PULUMI_LOCAL_DOCS_DIR
|
|
26
|
+
else process.env.PULUMI_LOCAL_DOCS_DIR = originalEnv
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Drives the MCP server the same way the stdio entry point does — a plain in-process
|
|
31
|
+
* server with a transport attached, no HTTP server and no TanStack Start runtime.
|
|
32
|
+
*/
|
|
33
|
+
async function connectClient(): Promise<Client> {
|
|
34
|
+
const [clientTransport, serverTransport] =
|
|
35
|
+
InMemoryTransport.createLinkedPair()
|
|
36
|
+
const client = new Client({ name: 'test-client', version: '0.0.0' })
|
|
37
|
+
await Promise.all([
|
|
38
|
+
createMcpServer().connect(serverTransport),
|
|
39
|
+
client.connect(clientTransport),
|
|
40
|
+
])
|
|
41
|
+
return client
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function parseTextResult(result: unknown): unknown {
|
|
45
|
+
const { content } = result as {
|
|
46
|
+
content?: Array<{ type: string; text: string }>
|
|
47
|
+
}
|
|
48
|
+
expect(content?.[0]?.type).toBe('text')
|
|
49
|
+
return JSON.parse(content![0]!.text)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('createMcpServer', () => {
|
|
53
|
+
it('advertises the provider docs tools', async () => {
|
|
54
|
+
const client = await connectClient()
|
|
55
|
+
|
|
56
|
+
const { tools } = await client.listTools()
|
|
57
|
+
|
|
58
|
+
expect(tools.map((tool) => tool.name).sort()).toEqual([
|
|
59
|
+
'get_function',
|
|
60
|
+
'get_resource',
|
|
61
|
+
'list_functions',
|
|
62
|
+
'list_providers',
|
|
63
|
+
'list_resources',
|
|
64
|
+
'search_members',
|
|
65
|
+
])
|
|
66
|
+
await client.close()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('reports the discovered project from list_providers', async () => {
|
|
70
|
+
const client = await connectClient()
|
|
71
|
+
|
|
72
|
+
const result = await client.callTool({ name: 'list_providers' })
|
|
73
|
+
|
|
74
|
+
expect(parseTextResult(result)).toMatchObject({
|
|
75
|
+
targetDir: projectDir,
|
|
76
|
+
project: { root: projectDir, name: 'my-project', runtime: 'nodejs' },
|
|
77
|
+
providers: [],
|
|
78
|
+
})
|
|
79
|
+
await client.close()
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('returns no matches from search_members when the project has no packages', async () => {
|
|
83
|
+
const client = await connectClient()
|
|
84
|
+
|
|
85
|
+
const result = await client.callTool({
|
|
86
|
+
name: 'search_members',
|
|
87
|
+
arguments: { query: 'bucket' },
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
expect(parseTextResult(result)).toEqual({
|
|
91
|
+
query: 'bucket',
|
|
92
|
+
matchCount: 0,
|
|
93
|
+
matches: [],
|
|
94
|
+
})
|
|
95
|
+
await client.close()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('surfaces a tool error for a provider that is not declared in Pulumi.yaml', async () => {
|
|
99
|
+
const client = await connectClient()
|
|
100
|
+
|
|
101
|
+
const result = await client.callTool({
|
|
102
|
+
name: 'list_resources',
|
|
103
|
+
arguments: { provider: 'nope' },
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
expect(result.isError).toBe(true)
|
|
107
|
+
expect(JSON.stringify(result.content)).toContain(
|
|
108
|
+
'No local provider named \\"nope\\"',
|
|
109
|
+
)
|
|
110
|
+
await client.close()
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -2,8 +2,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
|
2
2
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
|
3
3
|
import { z } from 'zod'
|
|
4
4
|
|
|
5
|
-
import { getProvider, listProviders } from '@/lib/pulumi/api'
|
|
6
|
-
import type { ProviderDetail } from '@/lib/pulumi/api'
|
|
7
5
|
import { resolveDescription } from '@/lib/pulumi/description'
|
|
8
6
|
import { getTargetDir, loadPulumiProject } from '@/lib/pulumi/discovery.server'
|
|
9
7
|
import {
|
|
@@ -14,6 +12,11 @@ import {
|
|
|
14
12
|
} from '@/lib/pulumi/examples'
|
|
15
13
|
import { formatType } from '@/lib/pulumi/format'
|
|
16
14
|
import { formatPropertyName } from '@/lib/pulumi/naming'
|
|
15
|
+
import {
|
|
16
|
+
loadProjectSummary,
|
|
17
|
+
loadProviderDetail,
|
|
18
|
+
} from '@/lib/pulumi/providers.server'
|
|
19
|
+
import type { ProviderDetail } from '@/lib/pulumi/providers.server'
|
|
17
20
|
import { resolveProviderSchema } from '@/lib/pulumi/schema.server'
|
|
18
21
|
import { tokenDisplayName } from '@/lib/pulumi/token'
|
|
19
22
|
import type {
|
|
@@ -30,7 +33,7 @@ function textResult(data: unknown): CallToolResult {
|
|
|
30
33
|
async function requireProvider(
|
|
31
34
|
providerName: string,
|
|
32
35
|
): Promise<ProviderDetail & { schema: NonNullable<ProviderDetail['schema']> }> {
|
|
33
|
-
const detail = await
|
|
36
|
+
const detail = await loadProviderDetail(providerName)
|
|
34
37
|
if (!detail) {
|
|
35
38
|
throw new Error(
|
|
36
39
|
`No local provider named "${providerName}" was found. Call list_providers to see the providers declared in this project's Pulumi.yaml.`,
|
|
@@ -100,8 +103,10 @@ function memberDetail(
|
|
|
100
103
|
}
|
|
101
104
|
|
|
102
105
|
/**
|
|
103
|
-
* Builds a fresh MCP server with the local-provider-docs tools registered.
|
|
104
|
-
*
|
|
106
|
+
* Builds a fresh MCP server with the local-provider-docs tools registered. Transport
|
|
107
|
+
* agnostic: `src/routes/mcp.ts` calls it once per HTTP request (so concurrent requests
|
|
108
|
+
* never share state), and `src/lib/pulumi/stdio.server.ts` calls it once for the
|
|
109
|
+
* lifetime of a stdio process.
|
|
105
110
|
*/
|
|
106
111
|
export function createMcpServer(): McpServer {
|
|
107
112
|
const server = new McpServer({
|
|
@@ -116,7 +121,7 @@ export function createMcpServer(): McpServer {
|
|
|
116
121
|
description:
|
|
117
122
|
"List the local/parameterized Pulumi providers declared in this project's Pulumi.yaml (added with `pulumi package add`), with their source, version, and resource/function counts.",
|
|
118
123
|
},
|
|
119
|
-
async () => textResult(await
|
|
124
|
+
async () => textResult(await loadProjectSummary()),
|
|
120
125
|
)
|
|
121
126
|
|
|
122
127
|
server.registerTool(
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { getTargetDir, loadPulumiProject } from '@/lib/pulumi/discovery.server'
|
|
2
|
+
import { resolveProviderSchema } from '@/lib/pulumi/schema.server'
|
|
3
|
+
import type { ProviderEntry, PulumiProject } from '@/lib/pulumi/types'
|
|
4
|
+
|
|
5
|
+
export interface ProviderSummary {
|
|
6
|
+
name: string
|
|
7
|
+
source: string
|
|
8
|
+
version?: string
|
|
9
|
+
parameters?: string[]
|
|
10
|
+
resourceCount: number
|
|
11
|
+
functionCount: number
|
|
12
|
+
origin: ProviderEntry['origin']
|
|
13
|
+
error?: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ProjectSummary {
|
|
17
|
+
targetDir: string
|
|
18
|
+
project: Pick<PulumiProject, 'root' | 'name' | 'runtime'> | null
|
|
19
|
+
providers: ProviderSummary[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ProviderDetail extends ProviderEntry {
|
|
23
|
+
/** The Pulumi project's runtime (e.g. "nodejs", "python", "go"), used to render Inputs/Outputs names in that language's casing. */
|
|
24
|
+
runtime?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The project + provider summaries shown on the index route and by the MCP
|
|
29
|
+
* `list_providers` tool. Plain async functions (no TanStack Start server-fn
|
|
30
|
+
* wrapper) so they can run either inside the web server or in the stdio MCP
|
|
31
|
+
* process, which has no HTTP server at all.
|
|
32
|
+
*/
|
|
33
|
+
export async function loadProjectSummary(): Promise<ProjectSummary> {
|
|
34
|
+
const targetDir = getTargetDir()
|
|
35
|
+
const project = loadPulumiProject(targetDir)
|
|
36
|
+
|
|
37
|
+
if (!project) {
|
|
38
|
+
return { targetDir, project: null, providers: [] }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const entries = await Promise.all(
|
|
42
|
+
project.packages.map((ref) => resolveProviderSchema(project.root, ref)),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
const providers: ProviderSummary[] = entries.map((entry) => ({
|
|
46
|
+
name: entry.ref.name,
|
|
47
|
+
source: entry.ref.source,
|
|
48
|
+
version: entry.ref.version,
|
|
49
|
+
parameters: entry.ref.parameters,
|
|
50
|
+
resourceCount: Object.keys(entry.schema?.resources ?? {}).length,
|
|
51
|
+
functionCount: Object.keys(entry.schema?.functions ?? {}).length,
|
|
52
|
+
origin: entry.origin,
|
|
53
|
+
error: entry.error,
|
|
54
|
+
}))
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
targetDir,
|
|
58
|
+
project: {
|
|
59
|
+
root: project.root,
|
|
60
|
+
name: project.name,
|
|
61
|
+
runtime: project.runtime,
|
|
62
|
+
},
|
|
63
|
+
providers,
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The fully resolved schema for one local provider, or null if the project or provider isn't found. */
|
|
68
|
+
export async function loadProviderDetail(
|
|
69
|
+
name: string,
|
|
70
|
+
): Promise<ProviderDetail | null> {
|
|
71
|
+
const targetDir = getTargetDir()
|
|
72
|
+
const project = loadPulumiProject(targetDir)
|
|
73
|
+
if (!project) return null
|
|
74
|
+
|
|
75
|
+
const ref = project.packages.find((p) => p.name === name)
|
|
76
|
+
if (!ref) return null
|
|
77
|
+
|
|
78
|
+
const entry = await resolveProviderSchema(project.root, ref)
|
|
79
|
+
return { ...entry, runtime: project.runtime }
|
|
80
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
2
|
+
|
|
3
|
+
import { createMcpServer } from '@/lib/pulumi/mcp.server'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Serves the same provider-docs tools as the `/mcp` HTTP route over stdio, for MCP
|
|
7
|
+
* clients that spawn a server process instead of connecting to a URL.
|
|
8
|
+
*
|
|
9
|
+
* Unlike the HTTP route this is a single long-lived server (one process, one client),
|
|
10
|
+
* and stdout is the protocol channel — nothing on this path may write to it, so any
|
|
11
|
+
* diagnostics belong on stderr.
|
|
12
|
+
*/
|
|
13
|
+
export async function runStdioMcpServer(): Promise<void> {
|
|
14
|
+
const server = createMcpServer()
|
|
15
|
+
await server.connect(new StdioServerTransport())
|
|
16
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
findTokenBySlug,
|
|
5
|
+
tokenDisplayName,
|
|
6
|
+
tokenSlug,
|
|
7
|
+
} from '@/lib/pulumi/token'
|
|
4
8
|
|
|
5
9
|
describe('tokenDisplayName', () => {
|
|
6
10
|
it('returns the segment after the last colon for a resource token', () => {
|
|
@@ -19,3 +23,42 @@ describe('tokenDisplayName', () => {
|
|
|
19
23
|
expect(tokenDisplayName('plainName')).toBe('plainName')
|
|
20
24
|
})
|
|
21
25
|
})
|
|
26
|
+
|
|
27
|
+
describe('tokenSlug', () => {
|
|
28
|
+
it('replaces the token separators with dashes', () => {
|
|
29
|
+
expect(tokenSlug('random:index/randomString:RandomString')).toBe(
|
|
30
|
+
'random-index-randomString-RandomString',
|
|
31
|
+
)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('leaves an already-safe token unchanged', () => {
|
|
35
|
+
expect(tokenSlug('plainName')).toBe('plainName')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('collapses a run of unsafe characters into a single dash', () => {
|
|
39
|
+
expect(tokenSlug('aws:s3//bucket:Bucket')).toBe('aws-s3-bucket-Bucket')
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('findTokenBySlug', () => {
|
|
44
|
+
const tokens = [
|
|
45
|
+
'random:index/randomString:RandomString',
|
|
46
|
+
'random:index/randomPet:RandomPet',
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
it('finds the token a slug was made from', () => {
|
|
50
|
+
expect(findTokenBySlug(tokens, 'random-index-randomPet-RandomPet')).toBe(
|
|
51
|
+
'random:index/randomPet:RandomPet',
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('also accepts the raw token', () => {
|
|
56
|
+
expect(
|
|
57
|
+
findTokenBySlug(tokens, 'random:index/randomString:RandomString'),
|
|
58
|
+
).toBe('random:index/randomString:RandomString')
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('returns undefined when nothing matches', () => {
|
|
62
|
+
expect(findTokenBySlug(tokens, 'nope')).toBeUndefined()
|
|
63
|
+
})
|
|
64
|
+
})
|
package/src/lib/pulumi/token.ts
CHANGED
|
@@ -8,3 +8,31 @@ export function tokenDisplayName(token: string): string {
|
|
|
8
8
|
const short = token.split(':').pop()
|
|
9
9
|
return short && short.length > 0 ? short : token
|
|
10
10
|
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A URL- and filesystem-safe form of a token, used as the `$token` route param.
|
|
14
|
+
*
|
|
15
|
+
* Tokens contain `:` and `/`, which only survive a URL as percent-escapes —
|
|
16
|
+
* and static hosts (GitHub/GitLab Pages) decode those before matching a file,
|
|
17
|
+
* so `random%3Aindex%2FrandomString%3ARandomString` would never resolve to the
|
|
18
|
+
* page generated for it. Replacing the separators keeps one URL shape that
|
|
19
|
+
* works both when served dynamically and as prerendered files on disk.
|
|
20
|
+
*/
|
|
21
|
+
export function tokenSlug(token: string): string {
|
|
22
|
+
return token.replace(/[^a-zA-Z0-9._~-]+/g, '-')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolves a `$token` route param back to the schema token it names. Accepts
|
|
27
|
+
* either a slug (what links produce) or the raw token, so a URL pasted from
|
|
28
|
+
* schema output still works.
|
|
29
|
+
*/
|
|
30
|
+
export function findTokenBySlug(
|
|
31
|
+
tokens: Iterable<string>,
|
|
32
|
+
slug: string,
|
|
33
|
+
): string | undefined {
|
|
34
|
+
for (const token of tokens) {
|
|
35
|
+
if (token === slug || tokenSlug(token) === slug) return token
|
|
36
|
+
}
|
|
37
|
+
return undefined
|
|
38
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when the app is being built as a static site (`pulumi-package-docs
|
|
3
|
+
* static`), for publishing to GitHub/GitLab Pages.
|
|
4
|
+
*
|
|
5
|
+
* A static build has no server behind it, so its pages can't call the server
|
|
6
|
+
* functions in `@/lib/pulumi/api` on the client. Every page is prerendered
|
|
7
|
+
* with its data already baked in and shipped without the hydration script:
|
|
8
|
+
* links then behave as ordinary anchors and each page load is self-contained,
|
|
9
|
+
* which also keeps a large provider's schema out of the pages that don't need
|
|
10
|
+
* it.
|
|
11
|
+
*
|
|
12
|
+
* Vite inlines `import.meta.env.VITE_*` at build time, so this folds away to a
|
|
13
|
+
* constant in both builds.
|
|
14
|
+
*/
|
|
15
|
+
export const isStaticDocs = import.meta.env.VITE_STATIC_DOCS === 'true'
|
package/src/router.tsx
CHANGED
|
@@ -4,6 +4,9 @@ import { routeTree } from './routeTree.gen'
|
|
|
4
4
|
export function getRouter() {
|
|
5
5
|
const router = createTanStackRouter({
|
|
6
6
|
routeTree,
|
|
7
|
+
// Set from Vite's `base`, so a static build published under a project
|
|
8
|
+
// path (e.g. https://<user>.github.io/<repo>/) still links correctly.
|
|
9
|
+
basepath: import.meta.env.BASE_URL,
|
|
7
10
|
scrollRestoration: true,
|
|
8
11
|
defaultPreload: 'intent',
|
|
9
12
|
defaultPreloadStaleTime: 0,
|
package/src/routes/__root.tsx
CHANGED
|
@@ -6,7 +6,8 @@ import {
|
|
|
6
6
|
} from '@tanstack/react-router'
|
|
7
7
|
|
|
8
8
|
import { PulumiLogo } from '@/components/PulumiLogo'
|
|
9
|
-
import { ThemeToggle } from '@/components/ThemeToggle'
|
|
9
|
+
import { StaticThemeToggle, ThemeToggle } from '@/components/ThemeToggle'
|
|
10
|
+
import { isStaticDocs } from '@/lib/static-docs'
|
|
10
11
|
import appCss from '../styles.css?url'
|
|
11
12
|
|
|
12
13
|
// Runs before hydration so an explicit theme choice applies before first
|
|
@@ -57,12 +58,14 @@ function RootDocument({ children }: { children: React.ReactNode }) {
|
|
|
57
58
|
<span className="site-brand-divider" aria-hidden="true" />
|
|
58
59
|
<span className="site-brand-name">Package Docs</span>
|
|
59
60
|
</Link>
|
|
60
|
-
<ThemeToggle />
|
|
61
|
+
{isStaticDocs ? <StaticThemeToggle /> : <ThemeToggle />}
|
|
61
62
|
</div>
|
|
62
63
|
</header>
|
|
63
64
|
{children}
|
|
64
65
|
|
|
65
|
-
|
|
66
|
+
{/* A static build has no server to call back into, so it ships
|
|
67
|
+
without the hydration script and navigates as plain links. */}
|
|
68
|
+
{isStaticDocs ? null : <Scripts />}
|
|
66
69
|
</body>
|
|
67
70
|
</html>
|
|
68
71
|
)
|