pulumi-package-docs 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +60 -0
- package/bin/cli.js +80 -0
- package/dist/client/assets/MemberDetail-CX9jiyic.js +1 -0
- package/dist/client/assets/index-CZRUHwIq.js +13 -0
- package/dist/client/assets/providers._name-DpU324kz.js +1 -0
- package/dist/client/assets/providers._name.functions._token-DXrP4mSE.js +1 -0
- package/dist/client/assets/providers._name.functions._token-bvc3vrG2.js +1 -0
- package/dist/client/assets/providers._name.index-CtKE1x7S.js +1 -0
- package/dist/client/assets/providers._name.index-tTXQzRr7.js +1 -0
- package/dist/client/assets/providers._name.resources._token--BZM8RBv.js +1 -0
- package/dist/client/assets/providers._name.resources._token-CbZkRkaE.js +1 -0
- package/dist/client/assets/routes-GAgiQcBX.js +1 -0
- package/dist/client/assets/styles-CWDprFhI.css +1 -0
- package/dist/client/assets/token-oQmZfAP3.js +1 -0
- package/dist/client/assets/useSelector-BIt42tGI.js +1 -0
- package/dist/server/assets/MemberDetail-BiBAI1vw.js +96 -0
- package/dist/server/assets/_tanstack-start-manifest_v-Cl1i6HDY.js +62 -0
- package/dist/server/assets/api-BV6lykkP.js +20 -0
- package/dist/server/assets/api-C55bS4R6.js +64 -0
- package/dist/server/assets/empty-plugin-adapters-D9UWiqvJ.js +5 -0
- package/dist/server/assets/providers._name-DF6sA3DG.js +6 -0
- package/dist/server/assets/providers._name.functions._token-C0A7Y90t.js +21 -0
- package/dist/server/assets/providers._name.functions._token-D8LsoIum.js +13 -0
- package/dist/server/assets/providers._name.index-BEwt-7U0.js +13 -0
- package/dist/server/assets/providers._name.index-BK_ZRd5l.js +97 -0
- package/dist/server/assets/providers._name.resources._token-CbvRkms4.js +22 -0
- package/dist/server/assets/providers._name.resources._token-DJPF8nMg.js +13 -0
- package/dist/server/assets/router-DZSocx9m.js +562 -0
- package/dist/server/assets/routes-B-G_iLTo.js +82 -0
- package/dist/server/assets/schema.server-B-VvU1qb.js +122 -0
- package/dist/server/assets/start-5Z2QO8AU.js +4 -0
- package/dist/server/server.js +2003 -0
- package/package.json +67 -0
- package/src/components/CodeExample.tsx +14 -0
- package/src/components/MemberDetail.tsx +89 -0
- package/src/components/PropertyTable.tsx +49 -0
- package/src/components/PulumiLogo.tsx +86 -0
- package/src/lib/pulumi/api.ts +77 -0
- package/src/lib/pulumi/description.test.ts +54 -0
- package/src/lib/pulumi/description.ts +35 -0
- package/src/lib/pulumi/discovery.server.test.ts +117 -0
- package/src/lib/pulumi/discovery.server.ts +77 -0
- package/src/lib/pulumi/examples.test.ts +97 -0
- package/src/lib/pulumi/examples.ts +55 -0
- package/src/lib/pulumi/format.test.ts +51 -0
- package/src/lib/pulumi/format.ts +11 -0
- package/src/lib/pulumi/mcp.server.ts +343 -0
- package/src/lib/pulumi/naming.test.ts +27 -0
- package/src/lib/pulumi/naming.ts +31 -0
- package/src/lib/pulumi/schema.server.ts +78 -0
- package/src/lib/pulumi/token.test.ts +21 -0
- package/src/lib/pulumi/token.ts +10 -0
- package/src/lib/pulumi/types.ts +62 -0
- package/src/routeTree.gen.ts +187 -0
- package/src/router.tsx +19 -0
- package/src/routes/__root.tsx +59 -0
- package/src/routes/index.tsx +78 -0
- package/src/routes/mcp.ts +30 -0
- package/src/routes/providers.$name.functions.$token.tsx +47 -0
- package/src/routes/providers.$name.index.tsx +129 -0
- package/src/routes/providers.$name.resources.$token.tsx +48 -0
- package/src/routes/providers.$name.tsx +5 -0
- package/src/styles.css +478 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { parse } from 'yaml'
|
|
5
|
+
|
|
6
|
+
import type { LocalPackageRef, PulumiProject } from '@/lib/pulumi/types'
|
|
7
|
+
|
|
8
|
+
const PROJECT_FILE_NAMES = ['Pulumi.yaml', 'Pulumi.yml']
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The directory of the Pulumi program to inspect. `bin/cli.js` sets this to the
|
|
12
|
+
* directory the user ran `npx pulumi-package-docs` from, since the dev/preview
|
|
13
|
+
* server itself runs with this package's own directory as its cwd.
|
|
14
|
+
*/
|
|
15
|
+
export function getTargetDir(): string {
|
|
16
|
+
return process.env.PULUMI_LOCAL_DOCS_DIR || process.cwd()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function findProjectFile(startDir: string): string | null {
|
|
20
|
+
let dir = startDir
|
|
21
|
+
for (;;) {
|
|
22
|
+
for (const fileName of PROJECT_FILE_NAMES) {
|
|
23
|
+
const candidate = join(dir, fileName)
|
|
24
|
+
if (existsSync(candidate)) return candidate
|
|
25
|
+
}
|
|
26
|
+
const parent = dirname(dir)
|
|
27
|
+
if (parent === dir) return null
|
|
28
|
+
dir = parent
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function toLocalPackageRefs(packages: unknown): LocalPackageRef[] {
|
|
33
|
+
if (!packages || typeof packages !== 'object') return []
|
|
34
|
+
|
|
35
|
+
return Object.entries(packages as Record<string, unknown>).map(
|
|
36
|
+
([name, value]) => {
|
|
37
|
+
const entry = (value ?? {}) as Record<string, unknown>
|
|
38
|
+
const parameters = Array.isArray(entry.parameters)
|
|
39
|
+
? entry.parameters.filter((p): p is string => typeof p === 'string')
|
|
40
|
+
: undefined
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
name,
|
|
44
|
+
source: typeof entry.source === 'string' ? entry.source : name,
|
|
45
|
+
version: typeof entry.version === 'string' ? entry.version : undefined,
|
|
46
|
+
parameters,
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Walks up from `startDir` looking for a Pulumi.yaml/Pulumi.yml, then reads its
|
|
54
|
+
* `packages` section to find the local/parameterized providers used by the project.
|
|
55
|
+
*/
|
|
56
|
+
export function loadPulumiProject(
|
|
57
|
+
startDir: string = getTargetDir(),
|
|
58
|
+
): PulumiProject | null {
|
|
59
|
+
const projectFile = findProjectFile(startDir)
|
|
60
|
+
if (!projectFile) return null
|
|
61
|
+
|
|
62
|
+
const raw = readFileSync(projectFile, 'utf-8')
|
|
63
|
+
const doc = parse(raw) as Record<string, unknown> | null
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
root: dirname(projectFile),
|
|
67
|
+
name: typeof doc?.name === 'string' ? doc.name : undefined,
|
|
68
|
+
runtime:
|
|
69
|
+
typeof doc?.runtime === 'string'
|
|
70
|
+
? doc.runtime
|
|
71
|
+
: typeof (doc?.runtime as Record<string, unknown> | undefined)?.name ===
|
|
72
|
+
'string'
|
|
73
|
+
? ((doc!.runtime as Record<string, unknown>).name as string)
|
|
74
|
+
: undefined,
|
|
75
|
+
packages: toLocalPackageRefs(doc?.packages),
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
extractExamples,
|
|
5
|
+
pickExample,
|
|
6
|
+
stripExamples,
|
|
7
|
+
summarizeDescription,
|
|
8
|
+
} from '@/lib/pulumi/examples'
|
|
9
|
+
|
|
10
|
+
const DESCRIPTION = `Generates random bytes.
|
|
11
|
+
|
|
12
|
+
## Example Usage
|
|
13
|
+
|
|
14
|
+
\`\`\`typescript
|
|
15
|
+
const bytes = new random.RandomBytes("bytes", {length: 32});
|
|
16
|
+
\`\`\`
|
|
17
|
+
\`\`\`python
|
|
18
|
+
bytes = random.RandomBytes("bytes", length=32)
|
|
19
|
+
\`\`\`
|
|
20
|
+
\`\`\`go
|
|
21
|
+
bytes, err := random.NewRandomBytes(ctx, "bytes", &random.RandomBytesArgs{Length: 32})
|
|
22
|
+
\`\`\`
|
|
23
|
+
`
|
|
24
|
+
|
|
25
|
+
describe('extractExamples', () => {
|
|
26
|
+
it('returns an empty list for no description', () => {
|
|
27
|
+
expect(extractExamples(undefined)).toEqual([])
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('returns an empty list when there are no fenced code blocks', () => {
|
|
31
|
+
expect(extractExamples('just some prose')).toEqual([])
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('extracts each labeled code block', () => {
|
|
35
|
+
expect(extractExamples(DESCRIPTION)).toEqual([
|
|
36
|
+
{
|
|
37
|
+
language: 'typescript',
|
|
38
|
+
code: 'const bytes = new random.RandomBytes("bytes", {length: 32});',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
language: 'python',
|
|
42
|
+
code: 'bytes = random.RandomBytes("bytes", length=32)',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
language: 'go',
|
|
46
|
+
code: 'bytes, err := random.NewRandomBytes(ctx, "bytes", &random.RandomBytesArgs{Length: 32})',
|
|
47
|
+
},
|
|
48
|
+
])
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('stripExamples', () => {
|
|
53
|
+
it('removes fenced code blocks, leaving the prose', () => {
|
|
54
|
+
expect(stripExamples(DESCRIPTION)).toBe(
|
|
55
|
+
'Generates random bytes.\n\n## Example Usage',
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('returns undefined when nothing but code blocks remain', () => {
|
|
60
|
+
expect(stripExamples('```typescript\ncode\n```')).toBeUndefined()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('summarizeDescription', () => {
|
|
65
|
+
it('returns the first paragraph, examples excluded', () => {
|
|
66
|
+
expect(summarizeDescription(DESCRIPTION)).toBe('Generates random bytes.')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('returns undefined for an empty description', () => {
|
|
70
|
+
expect(summarizeDescription(undefined)).toBeUndefined()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('pickExample', () => {
|
|
75
|
+
const examples = extractExamples(DESCRIPTION)
|
|
76
|
+
|
|
77
|
+
it('picks the example matching the runtime', () => {
|
|
78
|
+
expect(pickExample(examples, 'python')?.language).toBe('python')
|
|
79
|
+
expect(pickExample(examples, 'go')?.language).toBe('go')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('maps nodejs to typescript', () => {
|
|
83
|
+
expect(pickExample(examples, 'nodejs')?.language).toBe('typescript')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('falls back to the first example when the runtime has none', () => {
|
|
87
|
+
expect(pickExample(examples, 'dotnet')?.language).toBe('typescript')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('falls back to the first example when no runtime is given', () => {
|
|
91
|
+
expect(pickExample(examples)?.language).toBe('typescript')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('returns undefined for an empty example list', () => {
|
|
95
|
+
expect(pickExample([], 'python')).toBeUndefined()
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Pulumi package schema has no dedicated "examples" field — usage
|
|
3
|
+
* examples live as fenced, language-tagged code blocks embedded directly in
|
|
4
|
+
* a resource/function's markdown `description` (e.g. a "## Example Usage"
|
|
5
|
+
* section with one ```typescript/```python/```go/... block per language).
|
|
6
|
+
*/
|
|
7
|
+
export interface SchemaExample {
|
|
8
|
+
language: string
|
|
9
|
+
code: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const CODE_FENCE_RE = /```([a-zA-Z0-9+#-]+)\n([\s\S]*?)```/g
|
|
13
|
+
|
|
14
|
+
export function extractExamples(text?: string): SchemaExample[] {
|
|
15
|
+
if (!text) return []
|
|
16
|
+
return [...text.matchAll(CODE_FENCE_RE)].map((match) => ({
|
|
17
|
+
language: match[1].toLowerCase(),
|
|
18
|
+
code: match[2].trimEnd(),
|
|
19
|
+
}))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** The description text with any fenced code examples removed. */
|
|
23
|
+
export function stripExamples(text?: string): string | undefined {
|
|
24
|
+
if (!text) return text
|
|
25
|
+
const stripped = text.replace(CODE_FENCE_RE, '').trim()
|
|
26
|
+
return stripped || undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A short summary for list views: the first prose paragraph, examples excluded. */
|
|
30
|
+
export function summarizeDescription(text?: string): string | undefined {
|
|
31
|
+
const stripped = stripExamples(text)
|
|
32
|
+
if (!stripped) return undefined
|
|
33
|
+
return stripped.split(/\n{2,}/)[0]?.trim() || undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const RUNTIME_LANGUAGES: Record<string, string[]> = {
|
|
37
|
+
nodejs: ['typescript', 'javascript'],
|
|
38
|
+
python: ['python'],
|
|
39
|
+
go: ['go', 'golang'],
|
|
40
|
+
dotnet: ['csharp'],
|
|
41
|
+
java: ['java'],
|
|
42
|
+
yaml: ['yaml'],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Picks the example matching the project's runtime, falling back to the first available one. */
|
|
46
|
+
export function pickExample(
|
|
47
|
+
examples: SchemaExample[],
|
|
48
|
+
runtime?: string,
|
|
49
|
+
): SchemaExample | undefined {
|
|
50
|
+
const languages = runtime ? RUNTIME_LANGUAGES[runtime] : undefined
|
|
51
|
+
const match = languages
|
|
52
|
+
? examples.find((example) => languages.includes(example.language))
|
|
53
|
+
: undefined
|
|
54
|
+
return match ?? examples[0]
|
|
55
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { formatType } from '@/lib/pulumi/format'
|
|
4
|
+
|
|
5
|
+
describe('formatType', () => {
|
|
6
|
+
it('returns "any" for an undefined property', () => {
|
|
7
|
+
expect(formatType(undefined)).toBe('any')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('returns "any" for a property with neither a type nor a $ref', () => {
|
|
11
|
+
expect(formatType({})).toBe('any')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('returns the plain type for a scalar property', () => {
|
|
15
|
+
expect(formatType({ type: 'string' })).toBe('string')
|
|
16
|
+
expect(formatType({ type: 'boolean' })).toBe('boolean')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('shortens a $ref to its last path segment', () => {
|
|
20
|
+
expect(
|
|
21
|
+
formatType({ $ref: '#/types/random:index/randomString:RandomString' }),
|
|
22
|
+
).toBe('randomString:RandomString')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('url-decodes the shortened $ref', () => {
|
|
26
|
+
expect(
|
|
27
|
+
formatType({
|
|
28
|
+
$ref: '#/resources/pulumi:providers:random%2FterraformConfig',
|
|
29
|
+
}),
|
|
30
|
+
).toBe('pulumi:providers:random/terraformConfig')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('formats an array of a scalar type', () => {
|
|
34
|
+
expect(formatType({ type: 'array', items: { type: 'string' } })).toBe(
|
|
35
|
+
'string[]',
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('formats an array of $ref items', () => {
|
|
40
|
+
expect(
|
|
41
|
+
formatType({
|
|
42
|
+
type: 'array',
|
|
43
|
+
items: { $ref: '#/types/random:index/randomString:RandomString' },
|
|
44
|
+
}),
|
|
45
|
+
).toBe('randomString:RandomString[]')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('falls back to "any" for an array with no items', () => {
|
|
49
|
+
expect(formatType({ type: 'array' })).toBe('array')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SchemaProperty } from '@/lib/pulumi/types'
|
|
2
|
+
|
|
3
|
+
export function formatType(prop?: SchemaProperty): string {
|
|
4
|
+
if (!prop) return 'any'
|
|
5
|
+
if (prop.$ref) {
|
|
6
|
+
const short = prop.$ref.split('/').pop() ?? prop.$ref
|
|
7
|
+
return decodeURIComponent(short)
|
|
8
|
+
}
|
|
9
|
+
if (prop.type === 'array' && prop.items) return `${formatType(prop.items)}[]`
|
|
10
|
+
return prop.type ?? 'any'
|
|
11
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
|
2
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
|
3
|
+
import { z } from 'zod'
|
|
4
|
+
|
|
5
|
+
import { getProvider, listProviders } from '@/lib/pulumi/api'
|
|
6
|
+
import type { ProviderDetail } from '@/lib/pulumi/api'
|
|
7
|
+
import { resolveDescription } from '@/lib/pulumi/description'
|
|
8
|
+
import { getTargetDir, loadPulumiProject } from '@/lib/pulumi/discovery.server'
|
|
9
|
+
import {
|
|
10
|
+
extractExamples,
|
|
11
|
+
pickExample,
|
|
12
|
+
stripExamples,
|
|
13
|
+
summarizeDescription,
|
|
14
|
+
} from '@/lib/pulumi/examples'
|
|
15
|
+
import { formatType } from '@/lib/pulumi/format'
|
|
16
|
+
import { formatPropertyName } from '@/lib/pulumi/naming'
|
|
17
|
+
import { resolveProviderSchema } from '@/lib/pulumi/schema.server'
|
|
18
|
+
import { tokenDisplayName } from '@/lib/pulumi/token'
|
|
19
|
+
import type {
|
|
20
|
+
SchemaFunction,
|
|
21
|
+
SchemaProperty,
|
|
22
|
+
SchemaResource,
|
|
23
|
+
} from '@/lib/pulumi/types'
|
|
24
|
+
|
|
25
|
+
function textResult(data: unknown): CallToolResult {
|
|
26
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Loads a local provider's resolved schema, throwing a descriptive error a tool call can surface to the client. */
|
|
30
|
+
async function requireProvider(
|
|
31
|
+
providerName: string,
|
|
32
|
+
): Promise<ProviderDetail & { schema: NonNullable<ProviderDetail['schema']> }> {
|
|
33
|
+
const detail = await getProvider({ data: providerName })
|
|
34
|
+
if (!detail) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`No local provider named "${providerName}" was found. Call list_providers to see the providers declared in this project's Pulumi.yaml.`,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
if (!detail.schema) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Failed to load the schema for provider "${providerName}": ${detail.error ?? 'unknown error'}`,
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
return { ...detail, schema: detail.schema }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function summarizeMember(
|
|
48
|
+
token: string,
|
|
49
|
+
member: SchemaResource | SchemaFunction,
|
|
50
|
+
runtime?: string,
|
|
51
|
+
) {
|
|
52
|
+
return {
|
|
53
|
+
token,
|
|
54
|
+
name: tokenDisplayName(token),
|
|
55
|
+
deprecated: Boolean(
|
|
56
|
+
'deprecationMessage' in member && member.deprecationMessage,
|
|
57
|
+
),
|
|
58
|
+
summary: summarizeDescription(
|
|
59
|
+
resolveDescription(member.description, runtime),
|
|
60
|
+
),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatProperties(
|
|
65
|
+
properties: Record<string, SchemaProperty> | undefined,
|
|
66
|
+
required: string[] | undefined,
|
|
67
|
+
runtime: string | undefined,
|
|
68
|
+
) {
|
|
69
|
+
const requiredSet = new Set(required ?? [])
|
|
70
|
+
return Object.entries(properties ?? {}).map(([name, prop]) => ({
|
|
71
|
+
name: formatPropertyName(name, runtime),
|
|
72
|
+
type: formatType(prop),
|
|
73
|
+
required: requiredSet.has(name),
|
|
74
|
+
description: resolveDescription(prop.description, runtime),
|
|
75
|
+
}))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function memberDetail(
|
|
79
|
+
provider: string,
|
|
80
|
+
token: string,
|
|
81
|
+
description: string | undefined,
|
|
82
|
+
runtime: string | undefined,
|
|
83
|
+
inputs: ReturnType<typeof formatProperties>,
|
|
84
|
+
outputs: ReturnType<typeof formatProperties>,
|
|
85
|
+
deprecationMessage?: string,
|
|
86
|
+
) {
|
|
87
|
+
const resolved = resolveDescription(description, runtime)
|
|
88
|
+
const example = pickExample(extractExamples(resolved), runtime)
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
provider,
|
|
92
|
+
token,
|
|
93
|
+
name: tokenDisplayName(token),
|
|
94
|
+
deprecationMessage,
|
|
95
|
+
description: stripExamples(resolved),
|
|
96
|
+
example: example ?? null,
|
|
97
|
+
inputs,
|
|
98
|
+
outputs,
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Builds a fresh MCP server with the local-provider-docs tools registered. Called once
|
|
104
|
+
* per HTTP request (see `src/routes/mcp.ts`) so concurrent requests never share state.
|
|
105
|
+
*/
|
|
106
|
+
export function createMcpServer(): McpServer {
|
|
107
|
+
const server = new McpServer({
|
|
108
|
+
name: 'pulumi-package-docs',
|
|
109
|
+
version: '0.1.0',
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
server.registerTool(
|
|
113
|
+
'list_providers',
|
|
114
|
+
{
|
|
115
|
+
title: 'List local providers',
|
|
116
|
+
description:
|
|
117
|
+
"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
|
+
},
|
|
119
|
+
async () => textResult(await listProviders()),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
server.registerTool(
|
|
123
|
+
'list_resources',
|
|
124
|
+
{
|
|
125
|
+
title: 'List a provider’s resources',
|
|
126
|
+
description:
|
|
127
|
+
'List the resource tokens for a local provider, each with a display name, a one-line summary, and whether it is deprecated. Use get_resource for full details (inputs/outputs/example) on one of them.',
|
|
128
|
+
inputSchema: {
|
|
129
|
+
provider: z
|
|
130
|
+
.string()
|
|
131
|
+
.describe(
|
|
132
|
+
'The provider name as it appears in Pulumi.yaml and list_providers, e.g. "random".',
|
|
133
|
+
),
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
async ({ provider }) => {
|
|
137
|
+
const detail = await requireProvider(provider)
|
|
138
|
+
const resources = Object.entries(detail.schema.resources ?? {}).map(
|
|
139
|
+
([token, resource]) => summarizeMember(token, resource, detail.runtime),
|
|
140
|
+
)
|
|
141
|
+
return textResult({
|
|
142
|
+
provider,
|
|
143
|
+
resourceCount: resources.length,
|
|
144
|
+
resources,
|
|
145
|
+
})
|
|
146
|
+
},
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
server.registerTool(
|
|
150
|
+
'list_functions',
|
|
151
|
+
{
|
|
152
|
+
title: 'List a provider’s functions',
|
|
153
|
+
description:
|
|
154
|
+
'List the function (data source) tokens for a local provider, each with a display name, a one-line summary, and whether it is deprecated. Use get_function for full details (inputs/outputs/example) on one of them.',
|
|
155
|
+
inputSchema: {
|
|
156
|
+
provider: z
|
|
157
|
+
.string()
|
|
158
|
+
.describe(
|
|
159
|
+
'The provider name as it appears in Pulumi.yaml and list_providers, e.g. "random".',
|
|
160
|
+
),
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
async ({ provider }) => {
|
|
164
|
+
const detail = await requireProvider(provider)
|
|
165
|
+
const functions = Object.entries(detail.schema.functions ?? {}).map(
|
|
166
|
+
([token, fn]) => summarizeMember(token, fn, detail.runtime),
|
|
167
|
+
)
|
|
168
|
+
return textResult({
|
|
169
|
+
provider,
|
|
170
|
+
functionCount: functions.length,
|
|
171
|
+
functions,
|
|
172
|
+
})
|
|
173
|
+
},
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
server.registerTool(
|
|
177
|
+
'get_resource',
|
|
178
|
+
{
|
|
179
|
+
title: 'Get resource details',
|
|
180
|
+
description:
|
|
181
|
+
'Get the full documentation for one resource of a local provider: its description, a usage example (in the Pulumi project’s own language), and its input and output properties (name, type, required, description). Get the exact token from list_resources first.',
|
|
182
|
+
inputSchema: {
|
|
183
|
+
provider: z.string().describe('The provider name, e.g. "random".'),
|
|
184
|
+
token: z
|
|
185
|
+
.string()
|
|
186
|
+
.describe(
|
|
187
|
+
'The resource’s full schema token from list_resources, e.g. "random:index/randomString:RandomString".',
|
|
188
|
+
),
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
async ({ provider, token }) => {
|
|
192
|
+
const detail = await requireProvider(provider)
|
|
193
|
+
const resource = detail.schema.resources?.[token]
|
|
194
|
+
if (!resource) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`No resource with token "${token}" in provider "${provider}". Call list_resources to find the exact token.`,
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
return textResult(
|
|
200
|
+
memberDetail(
|
|
201
|
+
provider,
|
|
202
|
+
token,
|
|
203
|
+
resource.description,
|
|
204
|
+
detail.runtime,
|
|
205
|
+
formatProperties(
|
|
206
|
+
resource.inputProperties,
|
|
207
|
+
resource.requiredInputs,
|
|
208
|
+
detail.runtime,
|
|
209
|
+
),
|
|
210
|
+
formatProperties(
|
|
211
|
+
resource.properties,
|
|
212
|
+
resource.required,
|
|
213
|
+
detail.runtime,
|
|
214
|
+
),
|
|
215
|
+
resource.deprecationMessage,
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
server.registerTool(
|
|
222
|
+
'get_function',
|
|
223
|
+
{
|
|
224
|
+
title: 'Get function details',
|
|
225
|
+
description:
|
|
226
|
+
'Get the full documentation for one function (data source) of a local provider: its description, a usage example (in the Pulumi project’s own language), and its input and output properties. Get the exact token from list_functions first.',
|
|
227
|
+
inputSchema: {
|
|
228
|
+
provider: z.string().describe('The provider name, e.g. "random".'),
|
|
229
|
+
token: z
|
|
230
|
+
.string()
|
|
231
|
+
.describe(
|
|
232
|
+
'The function’s full schema token from list_functions, e.g. "random:index/randomInteger:getRandomInteger".',
|
|
233
|
+
),
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
async ({ provider, token }) => {
|
|
237
|
+
const detail = await requireProvider(provider)
|
|
238
|
+
const fn = detail.schema.functions?.[token]
|
|
239
|
+
if (!fn) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`No function with token "${token}" in provider "${provider}". Call list_functions to find the exact token.`,
|
|
242
|
+
)
|
|
243
|
+
}
|
|
244
|
+
return textResult(
|
|
245
|
+
memberDetail(
|
|
246
|
+
provider,
|
|
247
|
+
token,
|
|
248
|
+
fn.description,
|
|
249
|
+
detail.runtime,
|
|
250
|
+
formatProperties(
|
|
251
|
+
fn.inputs?.properties,
|
|
252
|
+
fn.inputs?.required,
|
|
253
|
+
detail.runtime,
|
|
254
|
+
),
|
|
255
|
+
formatProperties(
|
|
256
|
+
fn.outputs?.properties,
|
|
257
|
+
fn.outputs?.required,
|
|
258
|
+
detail.runtime,
|
|
259
|
+
),
|
|
260
|
+
),
|
|
261
|
+
)
|
|
262
|
+
},
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
server.registerTool(
|
|
266
|
+
'search_members',
|
|
267
|
+
{
|
|
268
|
+
title: 'Search resources and functions',
|
|
269
|
+
description:
|
|
270
|
+
'Search resource and function tokens/descriptions across one or all local providers for a keyword. Use this instead of list_resources/list_functions when a provider is large or you only know roughly what you’re looking for (e.g. "bucket", "random string").',
|
|
271
|
+
inputSchema: {
|
|
272
|
+
query: z.string().describe('Case-insensitive text to search for.'),
|
|
273
|
+
provider: z
|
|
274
|
+
.string()
|
|
275
|
+
.optional()
|
|
276
|
+
.describe(
|
|
277
|
+
'Limit the search to this provider name. Omit to search all local providers.',
|
|
278
|
+
),
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
async ({ query, provider }) => {
|
|
282
|
+
const targetDir = getTargetDir()
|
|
283
|
+
const project = loadPulumiProject(targetDir)
|
|
284
|
+
if (!project) return textResult({ query, matchCount: 0, matches: [] })
|
|
285
|
+
|
|
286
|
+
const refs = provider
|
|
287
|
+
? project.packages.filter((p) => p.name === provider)
|
|
288
|
+
: project.packages
|
|
289
|
+
if (provider && refs.length === 0) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`No local provider named "${provider}" was found. Call list_providers to see the providers declared in this project's Pulumi.yaml.`,
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const needle = query.toLowerCase()
|
|
296
|
+
const matches: Array<{
|
|
297
|
+
provider: string
|
|
298
|
+
kind: 'resource' | 'function'
|
|
299
|
+
token: string
|
|
300
|
+
name: string
|
|
301
|
+
summary?: string
|
|
302
|
+
}> = []
|
|
303
|
+
|
|
304
|
+
for (const ref of refs) {
|
|
305
|
+
const entry = await resolveProviderSchema(project.root, ref)
|
|
306
|
+
if (!entry.schema) continue
|
|
307
|
+
|
|
308
|
+
const groups: Array<
|
|
309
|
+
[
|
|
310
|
+
kind: 'resource' | 'function',
|
|
311
|
+
members:
|
|
312
|
+
| Record<string, SchemaResource | SchemaFunction>
|
|
313
|
+
| undefined,
|
|
314
|
+
]
|
|
315
|
+
> = [
|
|
316
|
+
['resource', entry.schema.resources],
|
|
317
|
+
['function', entry.schema.functions],
|
|
318
|
+
]
|
|
319
|
+
|
|
320
|
+
for (const [kind, members] of groups) {
|
|
321
|
+
for (const [token, member] of Object.entries(members ?? {})) {
|
|
322
|
+
const name = tokenDisplayName(token)
|
|
323
|
+
const summary = summarizeDescription(
|
|
324
|
+
resolveDescription(member.description, project.runtime),
|
|
325
|
+
)
|
|
326
|
+
const haystack = `${name} ${token} ${summary ?? ''}`.toLowerCase()
|
|
327
|
+
if (haystack.includes(needle)) {
|
|
328
|
+
matches.push({ provider: ref.name, kind, token, name, summary })
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return textResult({
|
|
335
|
+
query,
|
|
336
|
+
matchCount: matches.length,
|
|
337
|
+
matches: matches.slice(0, 50),
|
|
338
|
+
})
|
|
339
|
+
},
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
return server
|
|
343
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { formatPropertyName } from '@/lib/pulumi/naming'
|
|
4
|
+
|
|
5
|
+
describe('formatPropertyName', () => {
|
|
6
|
+
it('leaves the name as-is when no runtime is given', () => {
|
|
7
|
+
expect(formatPropertyName('bucketName')).toBe('bucketName')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('leaves the name as-is for nodejs, java, and yaml', () => {
|
|
11
|
+
expect(formatPropertyName('bucketName', 'nodejs')).toBe('bucketName')
|
|
12
|
+
expect(formatPropertyName('bucketName', 'java')).toBe('bucketName')
|
|
13
|
+
expect(formatPropertyName('bucketName', 'yaml')).toBe('bucketName')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('converts to snake_case for python', () => {
|
|
17
|
+
expect(formatPropertyName('bucketName', 'python')).toBe('bucket_name')
|
|
18
|
+
expect(formatPropertyName('s3BucketName', 'python')).toBe('s3_bucket_name')
|
|
19
|
+
expect(formatPropertyName('id', 'python')).toBe('id')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('converts to PascalCase for go and dotnet', () => {
|
|
23
|
+
expect(formatPropertyName('bucketName', 'go')).toBe('BucketName')
|
|
24
|
+
expect(formatPropertyName('bucketName', 'dotnet')).toBe('BucketName')
|
|
25
|
+
expect(formatPropertyName('s3BucketName', 'go')).toBe('S3BucketName')
|
|
26
|
+
})
|
|
27
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulumi schema property names are always camelCase; each language's generated
|
|
3
|
+
* SDK renders them in that language's own convention. This approximates that
|
|
4
|
+
* per-runtime rendering for display purposes (it doesn't reproduce Pulumi's
|
|
5
|
+
* codegen exactly, e.g. its acronym handling, but matches the common case).
|
|
6
|
+
*/
|
|
7
|
+
function toSnakeCase(name: string): string {
|
|
8
|
+
return name
|
|
9
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
10
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2')
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function toPascalCase(name: string): string {
|
|
15
|
+
if (name.length === 0) return name
|
|
16
|
+
return name.charAt(0).toUpperCase() + name.slice(1)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Renders a schema property name the way it would appear in the given Pulumi runtime's generated SDK. */
|
|
20
|
+
export function formatPropertyName(name: string, runtime?: string): string {
|
|
21
|
+
switch (runtime) {
|
|
22
|
+
case 'python':
|
|
23
|
+
return toSnakeCase(name)
|
|
24
|
+
case 'go':
|
|
25
|
+
case 'dotnet':
|
|
26
|
+
return toPascalCase(name)
|
|
27
|
+
// nodejs, java, and yaml all use the schema's camelCase names as-is.
|
|
28
|
+
default:
|
|
29
|
+
return name
|
|
30
|
+
}
|
|
31
|
+
}
|