Initial commit

This commit is contained in:
Adam Jones 2025-09-20 02:41:11 +00:00
commit e3f0af2f94
No known key found for this signature in database
16 changed files with 3857 additions and 0 deletions

28
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,28 @@
name: CI
on:
pull_request:
branches: [ main ]
permissions:
contents: read
jobs:
build:
name: Build and Typecheck
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build

51
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,51 @@
name: Deploy
on:
push:
branches:
- main
permissions:
contents: read
env:
PULUMI_VERSION: "3.197.0"
jobs:
deploy-production:
name: Deploy to Production
runs-on: ubuntu-latest
environment: production
concurrency:
group: deploy-production
cancel-in-progress: false
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Setup Pulumi
uses: pulumi/actions@v6
with:
pulumi-version: ${{ env.PULUMI_VERSION }}
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_PROD_SERVICE_ACCOUNT_KEY }}
- name: Deploy to Production
run: |
echo "${{ secrets.PULUMI_PROD_PASSPHRASE }}" > passphrase.prod.txt
make up

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
node_modules/
bin/
*.log
# Pulumi
.pulumi/
Pulumi.*.yaml.bak
sdks
# Secrets
passphrase.prod.txt
sa-key.json

16
Makefile Normal file
View file

@ -0,0 +1,16 @@
.PHONY: help login preview up
# Default target
help: ## Show this help message
@echo "Available targets:"
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $$1, $$2}'
# Production stack commands
login: ## Login to Pulumi backend (GCS)
pulumi login gs://mcp-access-prod-pulumi-state
preview: login ## Preview infrastructure changes
PULUMI_CONFIG_PASSPHRASE_FILE=passphrase.prod.txt pulumi preview --stack prod
up: login ## Deploy infrastructure
PULUMI_CONFIG_PASSPHRASE_FILE=passphrase.prod.txt pulumi up --yes --stack prod

8
Pulumi.prod.yaml Normal file
View file

@ -0,0 +1,8 @@
encryptionsalt: v1:N7wjHl1hvH8=:v1:VT8UbYOIS0kNADr5:fSnvGX+is6htXDjLS03RDObRnzGEVA==
config:
googleworkspace:customerId: C01dja0mo
googleworkspace:credentials:
secure: v1:Ntr2su0kgwp0oAh3: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
github:owner: modelcontextprotocol
github:token:
secure: v1:RKjKUcq1e0V7kVNf:5PP+vWbnRzgX8MmhF/yp52Fh78cTMBYz798JNc9LiyrubSt4AAZH/y/UA6kErP0FmXfFLRpdDIMmrR3RiNQlCe7nbkB6hf0CsV8mg8U7oB0EkA5wnRlqrZaKBri5l5puy2ZBFWjLhHr6oC714A==

9
Pulumi.yaml Normal file
View file

@ -0,0 +1,9 @@
name: mcp-access
runtime: nodejs
description: Infrastructure as Code for MCP access management
packages:
googleworkspace:
source: terraform-provider
version: 0.14.0
parameters:
- hashicorp/googleworkspace

107
README.md Normal file
View file

@ -0,0 +1,107 @@
# MCP Access Management
Infrastructure as Code for managing access to MCP community resources using Pulumi.
- Define groups in [`src/config/groups.ts`](src/config/groups.ts)
- Add users to groups in [`src/config/users.ts`](src/config/users.ts)
- Changes are applied via GitHub Actions when merged to the main branch
## What This Manages
- **GitHub Teams**: Automatically syncs team memberships in the MCP GitHub organization
- **Google Workspace Groups**: Automatically syncs group memberships for @modelcontextprotocol.io email accounts
## Deployment
### Production Deployment (Automated)
**Note:** Production deployment is automatically handled by GitHub Actions. All merges to the `main` branch trigger an automatic deployment via [the configured GitHub Actions workflow](.github/workflows/deploy.yml).
### Manual Deployment
Pre-requisites:
- [Pulumi CLI installed](https://www.pulumi.com/docs/iac/download-install/)
- [Google Cloud SDK installed](https://cloud.google.com/sdk/docs/install)
- Access to GCP project and GCS bucket
- Required credentials and secrets
1. Authenticate with GCP: `gcloud auth application-default login`
2. Get the passphrase file `passphrase.prod.txt` from the maintainers
3. Preview changes: `make preview`
4. Deploy changes: `make up`
## Key Management
### Required GitHub Secrets (for CI/CD)
The following secrets must be configured in GitHub Actions for automated deployments:
- **`GCP_PROD_SERVICE_ACCOUNT_KEY`**: GCP service account key
- Used to authenticate with Google Cloud Storage for Pulumi state (`gs://mcp-access-prod-pulumi-state`)
- Should be a JSON key file for a service account with Storage Admin permissions
- See "Setting Up GCS Backend" below for setup instructions
- **`PULUMI_PROD_PASSPHRASE`**: Passphrase for encrypting Pulumi state
- Used to decrypt encrypted values in Pulumi stack configuration
- Keep this secure - if lost, you cannot decrypt your Pulumi state
## Initial Setup
If setting up this infrastructure for the first time:
### 1. Set Up Service Account
```bash
# Create project and enable APIs
gcloud projects create mcp-access-prod
gcloud config set project mcp-access-prod
gcloud services enable storage.googleapis.com
gcloud services enable admin.googleapis.com
gcloud services enable groupssettings.googleapis.com
# Create service account
gcloud iam service-accounts create pulumi-svc \
--display-name="MCP Access Management Service Account" \
--description="Service account for Pulumi state and Google Workspace management"
# Grant storage admin permissions (for Pulumi state)
gcloud projects add-iam-policy-binding mcp-access-prod \
--member="serviceAccount:pulumi-svc@mcp-access-prod.iam.gserviceaccount.com" \
--role="roles/storage.admin"
# Create key
gcloud iam service-accounts keys create sa-key.json \
--iam-account=pulumi-svc@mcp-access-prod.iam.gserviceaccount.com
# Create GCS bucket for Pulumi state
gsutil mb gs://mcp-access-prod-pulumi-state
```
Then:
1. In Google Workspace Admin Console, go to **Account** → **Admin roles**
2. Select **Groups Admin** role (or create a custom role with these privileges):
- Read, create, update, and delete groups
- Read and update group members
3. Click **Assign service accounts**
4. Add your service account email: `pulumi-svc@mcp-access-prod.iam.gserviceaccount.com`
### 2. Initialize Pulumi Stack
```bash
# Login to Pulumi backend (GCS)
pulumi login gs://mcp-access-prod-pulumi-state
# Create production stack
export PULUMI_CONFIG_PASSPHRASE_FILE=passphrase.prod.txt
pulumi stack init prod
# Configure application secrets in Pulumi
pulumi config set --secret googleworkspace:credentials "$(cat sa-key.json)"
pulumi config set --secret github:token "ghp_your_github_token_here"
```
### 3. Configure GitHub Actions Secrets
Add the CI/CD secrets to GitHub Actions (repository settings → Secrets and variables → Actions):
- `GCP_PROD_SERVICE_ACCOUNT_KEY`: Content of `sa-key.json`
- `PULUMI_PROD_PASSPHRASE`: The passphrase you set above

3456
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

19
package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "@mcp/access",
"version": "1.0.0",
"description": "Infrastructure as Code for MCP access management",
"main": "src/index.ts",
"scripts": {
"build": "tsc"
},
"license": "MIT",
"dependencies": {
"@pulumi/github": "^6.7.3",
"@pulumi/googleworkspace": "file:sdks/googleworkspace",
"@pulumi/pulumi": "^3.197.0"
},
"devDependencies": {
"@types/node": "^22.18.6",
"typescript": "^5.9.2"
}
}

15
src/config/groups.ts Normal file
View file

@ -0,0 +1,15 @@
import { defineGroups } from './utils';
// NOTE: For GitHub teams, only the first memberOf will be used as the parent team.
// GitHub only supports one parent team per team.
export const GROUPS = defineGroups([
{
name: 'test-parent',
description: 'All maintainers. Users should not be added directly to this group.',
},
{
name: 'test-child',
description: 'Registry maintainers',
memberOf: ['test-parent'],
},
] as const);

9
src/config/users.ts Normal file
View file

@ -0,0 +1,9 @@
import type { Member } from './utils';
export const MEMBERS: readonly Member[] = [
{
github: 'domdomegg',
email: 'adam@modelcontextprotocol.io',
memberOf: ['test-child'],
},
] as const;

37
src/config/utils.ts Normal file
View file

@ -0,0 +1,37 @@
import type { GROUPS } from './groups';
function isValidGroupName(name: string): boolean {
return /^[a-z][a-z0-9-]*[a-z]$/.test(name);
}
export function defineGroups<
const T extends readonly {
name: Lowercase<string>;
description: string;
memberOf?: readonly (T[number]['name'])[];
}[]
>(groups: T) {
for (const group of groups) {
if (!isValidGroupName(group.name)) {
throw new Error(
`Invalid group name: ${group.name}. Must be lowercase alphanumeric with dashes, starting with a letter.`
);
}
}
return groups;
}
export type GroupKey = (typeof GROUPS)[number]['name'];
export interface Group {
name: GroupKey;
description: string;
memberOf?: readonly GroupKey[];
}
export interface Member {
github?: string;
email?: `${string}@modelcontextprotocol.io`;
memberOf: readonly GroupKey[];
}

27
src/github.ts Normal file
View file

@ -0,0 +1,27 @@
import * as github from '@pulumi/github';
import { GROUPS } from './config/groups';
import type { Group } from './config/utils';
import { MEMBERS } from './config/users';
const teams: Record<string, github.Team> = {};
GROUPS.forEach((group: Group) => {
teams[group.name] = new github.Team(group.name, {
name: group.name,
description: group.description + ' \n(Managed by github.com/modelcontextprotocol/access)',
privacy: 'closed',
parentTeamId: group.memberOf?.[0] ? teams[group.memberOf[0]].id : undefined,
});
});
MEMBERS.forEach((member) => {
if (!member.github) return;
member.memberOf.forEach((teamKey) => {
new github.TeamMembership(`${member.github}-${teamKey}`, {
teamId: teams[teamKey].id,
username: member.github!,
role: 'member',
});
});
});

45
src/google.ts Normal file
View file

@ -0,0 +1,45 @@
import * as gworkspace from '@pulumi/googleworkspace';
import { GROUPS } from './config/groups';
import type { Group } from './config/utils';
import { MEMBERS } from './config/users';
const groups: Record<string, gworkspace.Group> = {};
GROUPS.forEach((group: Group) => {
groups[group.name] = new gworkspace.Group(group.name, {
email: `${group.name}@modelcontextprotocol.io`,
name: group.name,
description: group.description + ' \n(Managed by github.com/modelcontextprotocol/access)',
});
new gworkspace.GroupSettings(group.name, {
email: groups[group.name].email,
whoCanContactOwner: 'ALL_OWNERS_CAN_CONTACT',
whoCanJoin: 'INVITED_CAN_JOIN',
whoCanLeaveGroup: 'NONE_CAN_LEAVE',
whoCanModerateMembers: 'OWNERS_ONLY',
whoCanPostMessage: 'ALL_OWNERS_CAN_POST',
whoCanViewGroup: 'ALL_MEMBERS_CAN_VIEW',
whoCanViewMembership: 'ALL_IN_DOMAIN_CAN_VIEW',
});
group.memberOf?.forEach((parentGroupKey) => {
new gworkspace.GroupMember(`${group.name}-in-${parentGroupKey}`, {
groupId: groups[parentGroupKey].id,
email: groups[group.name].email,
role: 'MEMBER',
});
});
});
MEMBERS.forEach((member) => {
if (!member.email) return;
member.memberOf.forEach((teamKey) => {
new gworkspace.GroupMember(`${member.email}-${teamKey}`, {
groupId: groups[teamKey].id,
email: member.email!,
role: 'MEMBER',
});
});
});

2
src/index.ts Normal file
View file

@ -0,0 +1,2 @@
export * from './github';
export * from './google';

16
tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"moduleResolution": "node",
"noEmit": true,
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}